DATATON BC 2018


Equipo: ZenAI

Integrantes:

  • Ana Isabel Rua
  • SebastiĆ”n Uribe
  • JuliĆ”n AndrĆ©s Aranzales

Descripción del reto

Los datos entregados en este reto corresponden a transacciones realizadas por clientes persona del banco vía PSE. Estas transacciones, a diferencia de las transacciones realizadas vía POS, no cuentan con un código MCC atado a la transacción, que permite conocer la categoría de comercio a la que pertence el establecimiento de comercio donde se realiza la transacción. Adicionalmente, muchas de estas transferencias por PSE corresponden a transferencias de pagos de servicios públicos, seguros, colegios, arrendamientos, y otros gastos que pueden ser denominados como gastos grandes. En el marco de un sistema de gestión de finanzas personales, poder categorizar adecuadamente estas transacciones que se realizan por PSE es de suma importancia para contar con una foto completa de la actividad de gastos de los clientes. Para este reto, los equipos participantes tendrÔn acceso a una muestra de transacciones PSE que corresponden a algo mÔs de 300 mil clientes (persona), seleccionados de manera aleatoria. La tabla de transacciones cuenta con 11.8 millones de registros (uno para cada transacción), realizados entre septiembre de 2016 y octubre de 2018.

NOTA Los datos han pasado por un proceso relativamente simple de curación, pero se han dejado algunos ruidos en la calidad de éstos con el fin de que los equipos también lleven a cabo un proceso de inspección y limpieza.

Tablas

dt_trxpse_personas_2016_2018_muestra_adjt

  • Tabla con transacciones PSE durante 2016-09 a 2018-10 (muestra aleatoria de clientes persona -- 340 mil clientes --)
Campo Descripción Tipo
id_trn_ach identificador único de transacción string
id_cliente id. Ćŗnico de cliente (pagador) bigint
fecha fecha de transacción decimal(8,0)
hora hora de transacción (HHMMSS) decimal(6,0)
valor_trx valor ($) transacción double
ref1 texto libre referencia 1 string
ref2 texto libre referencia 2 string
ref3 texto libre referencia 3 string
sector sector eco. receptor varchar(24)
subsector subsector eco. receptor varchar(62)
descripcion descripción subsector receptor varchar(24)

dt_info_pagadores_muestra

  • Tabla con (alguna) información demogrĆ”fica de los pagadores (muestra aleatoria de 340 mil clientes)
Campo Descripción Tipo
id_cliente id. Ćŗnico de cliente (pagador) bigint
seg_str segmento estructural string
ocupacion ocupación string
tipo_vivienda tipo de vivienda string
nivel_academico nivel acadƩmico string
estado_civil estado civil string
genero genero string
edad edad int
ingreso_rango rango de ingreso estimado string

El seg_str corresponde a la segmentación estructural, que solo depende de los ingresos reportados por el cliente y su tamaño comercial (volumen de activos y pasivos con el banco). Posibles valores son PERSONAL, PERSONAL PLUS, EMPRENDEDOR, PREFERENCIAL, OTRO (incluye también clientes no segmentados debido a falta de información de éstos).

ocupacion

Código Descripción
E SOCIO O EMPLEADO - SOCIO
I DESEMPLEADO CON INGRESOS
O OTRA
P INDEPENDIENTE
S DESEMPLEADO SIN INGRESOS
1 EMPLEADO
2 ESTUDIANTE
3 INDEPENDIENTE
4 HOGAR
5 JUBILADO
6 AGRICULTOR
7 GANADERO
8 COMERCIANTE
9 RENTISTA DE CAPITAL

Si en los datos aparece algún otro código no listado en la tabla anterior, es posible asumir que se trata de un valor nulo, no disponible para el cliente en cuestión.

tipo_vivienda

Código Descripción
A ALQUILADA
R ALQUILADA
F FAMILIAR
I NO INFORMA
P PROPIA
O PROPIA

Si en los datos aparece algún otro código no listado en la tabla anterior, es posible asumir que se trata de un valor nulo, no disponible para el cliente en cuestión.

nivel_academico

Código Descripción
H BACHILLERATO
B BACHILLERATO
U UNIVERSITARIO
E ESPECIALIZACION
N NINGUNO
P PRIMARIA
S POSTGRADO
T TECNICO
I NO INFORMA

estado_civil

Código Descripción
S SOLTERO
M CASADO
F DESCONOCIDO
I NO INFORMA
D DIVORCIADO
W VIUDO
O OTRO

genero

Código Descripción
F FEMENINO
M MASCULINO

Categorización propuesta por el equipo de analítica de personas

En el Banco ya se han llevado a cabo esfuerzos por categorizar transacciones provenientes del canal POS (con tarjetas débito y crédito), lo cual ha incluído, entre otras cosas, una depuración y limpieza de los códigos MCC. A continuación mostramos, a manera de referencia, la categorización propuesta por el equipo.

  1. Comida
  2. Hogar
  3. Cuidado personal
  4. Entretenimiento
  5. Educación
  6. Transporte
  7. Viajes
  8. Ahorro
  9. Pago de deudas
  10. Ingresos
  11. Retiros en efectivo
  12. Mascotas
  13. Moda
  14. TecnologĆ­a y comunicaciones
  15. Otros

NOTA IMPORTANTE

Recuerden que esta información aún contiene un elevado nivel de ruido. No solo no ha sido depurada de posibles datos atípicos (transacciones de valor muy elevado) fruto de errores o transacciones fallidas, sino que también cuenta con el ruido asociado al campo de referencia, donde se involucra el factor humano, ya que son campos de texto libre que pueden contener cualquier tipo de información.

Por seguridad, hemos eliminado cualquier número presente en dichos campos de referencia (cédulas, nits, montos, contratos, etc.).

Metodología de la Solución Propuesta

Para la solución a este reto se utilizarÔ la metodología CRISP-DM, mediante la cual se busca generar un resultado que haga un uso adecuado de los datos y permita resolver de forma efectiva el problema inicial planteado. De esta forma, se seguirÔn los siguientes pasos:

  1. Entendimiento del Negocio
  2. Entendimiento de los Datos
  3. Preparación de los Datos
  4. Modelamiento
  5. Evaluación
  6. Despliegue

Se debe tener presente tambiƩn que a partir del entendimiento del negocio se plantearƔn varias soluciones diferentes, que puedan aportar desde diferentes perspectivas tanto a los clientes del banco (pagadores) como al banco por sƭ mismo.

1. Entendimiento del Negocio

En esta fase se buscarÔ entender el negocio en lo referente al funcionamiento del botón de Pagos seguros en línea PSE.

PSE es un sistema centralizado y estandarizado que permite a las empresas ofrecer al Usuario la posibilidad de realizar pagos en lĆ­nea, accesando sus recursos desde la Entidad Financiera donde los tiene.

PSE es un servicio de ACH COLOMBIA S.A. quien es miembro de la Asociación Nacional de CÔmaras de Compensación Automatizadas de Estados Unidos conocida como entidad que rige los procedimientos, normas y formatos de los ACH en ese país, donde el sistema ACH existe hace mÔs de 25 años.

PSE cuenta en colombia con mÔs de 6000 empresas suscritas para la realización de pagos mediante el portal.

Entidades financieras vinculadas al servicio PSE

  • BANCO AGRARIO
  • BANCO AV VILLAS
  • BANCO CAJA SOCIAL
  • BANCO COLPATRIA
  • BANCO DAVIVIENDA
  • BANCO DE BOGOTA
  • BANCO DE OCCIDENTE
  • BANCO GNB SUDAMERIS
  • BANCO PICHINCHA S.A.
  • BANCO POPULAR
  • BANCO PROCREDIT
  • BANCOLOMBIA
  • BANCOOMEVA S.A.
  • BBVA COLOMBIA S.A.
  • CITIBANK
  • ITAÚ
  • BANCO FALABELLA

Algunas ventajas para Grandes, medianas y pequeƱas empresas

  • Confirma e identifica en lĆ­nea y en tiempo real las transacciones.
  • Concilia automĆ”ticamente la información.
  • Ahorra gastos operativos, tiempo y recursos.
  • Evita errores en pagos y/o recaudos.
  • Acceso a 18 millones de cuentas corrientes/ahorros en 17 entidades financieras.
  • Aumento en los niveles de recaudo.
  • Descongestiona los puntos de atención

Ventajas para usuarios

  • Permite hacer transacciones sin moverse de su hogar u oficina.
  • Brinda seguridad y agilidad al reducir el manejo de efectivo.
  • Facilita y ofrece comodidad en sus pagos y/o compras.
  • Disponible las 24 horas de dĆ­a, 7 dĆ­as a la semana y todos los dĆ­as del aƱo.
  • Confirma en lĆ­nea las transacciones

consultado en lĆ­nea en: https://bit.ly/2AuCbcY

El reto que se tiene es lograr clasificar las transacciones en una serie de categorĆ­as definidas por el banco, para lo cual el proceso que se seguirĆ” es:

  1. Utilizar varias metodologías asociadas a minería de texto, para asignar una categoría a cada transacción. Para esto se utilizarÔn metodologías como: Aplicación de relación campo categoría (específicamente para el campo "subsector"), Clasificación de transacciones que no tienen información, Web Scraping para obtener categorías de pÔginas que tengan información relevante, y uso de matrices TF para aplicarlas bolsas de palabras al set de datos, y finalmente clusterización.
  2. Una vez clasificados los 12M de registros iniciales según las categorías definidas, se procederÔ a generar un modelo que permita clasificar nuevas transacciones que entregue el banco, de tal forma que el proceso pueda llevarse a producción sobre nuevos registros, y se pueda generar valor tanto para el banco como para los clientes.
  3. Uso de la información de los clientes para crear clusters que permitan agruparlos y a partir de ello poder implementar modelos que le permitan al banco hacerles cross-selling, y al cliente tener una base de comparación sobre la cual pueda recibir recomendaciones si su comportamiento de gasto es superior al de personas con características similares.
  4. Uso de la información de los clientes para realizar un proceso de correlación que indique qué tipos de clientes son mÔs propensos a realizar cada tipo de transacción, es decir, no analizar solo cuÔles atributos son relevantes frente a cada calasificación, sino las categorías específicas de cada atributo.

Finalmente, se proponen una serie de mockups que permitan llevar los modelos propuestos a la prÔctica. Aunque esto es algo que posiblemente requiera trabajos mÔs profundos de co-creación, se hace esta aproximación para lograr que los modelos propuestos vayan mÔs allÔ de solo el procesamiento de datos, y llegue a una propuesta de generación de valor tanto para el banco como para el cliente.

2. Entendimiento de lo datos

En esta fase el objetivo principal es poder hacer una captura inicial de los datos a analizar para familiarizarse con ellos, identificar problemas de calidad de los mismos, detectar subconjuntos que pudieran ser interesantes para formular hipótesis, e incluso identificar las primeras claves del conocimiento que se puede extraer de los datos.

Este fase de la metodología comprende una serie de etapas que se listan a continuación:

  • Descipción de los datos: este acercamiento inicial permite identificar los tipos de variables, su tamaƱo, entre otros.

  • Exploración de los datos: permite tener una idea del comportamiento de los registros y posibles relaciones entre los campos del dataset. Esta exploración incluye generalmente un anĆ”lisis descriptivo a partir de la información estadĆ­stica del conjunto.

  • Calidad de los datos: brinda un diagnóstico del conjunto de datos, en esta actividad se verifica la presencia de valores duplicados, nulos y datos atĆ­picos que pueden afectar el desempeƱo de los modelos que se realicen.

In [43]:
# Carga las librerías para el procesamiento de la información
# https://medium.freecodecamp.org/how-to-transfer-large-files-to-google-colab-and-remote-jupyter-notebooks-26ca252892fa
!pip install PyDrive
import os
from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive
from google.colab import auth
from oauth2client.client import GoogleCredentials
Requirement already satisfied: PyDrive in /usr/local/lib/python3.6/dist-packages (1.3.1)
Requirement already satisfied: google-api-python-client>=1.2 in /usr/local/lib/python3.6/dist-packages (from PyDrive) (1.6.7)
Requirement already satisfied: oauth2client>=4.0.0 in /usr/local/lib/python3.6/dist-packages (from PyDrive) (4.1.3)
Requirement already satisfied: PyYAML>=3.0 in /usr/local/lib/python3.6/dist-packages (from PyDrive) (3.13)
Requirement already satisfied: six<2dev,>=1.6.1 in /usr/local/lib/python3.6/dist-packages (from google-api-python-client>=1.2->PyDrive) (1.11.0)
Requirement already satisfied: httplib2<1dev,>=0.9.2 in /usr/local/lib/python3.6/dist-packages (from google-api-python-client>=1.2->PyDrive) (0.11.3)
Requirement already satisfied: uritemplate<4dev,>=3.0.0 in /usr/local/lib/python3.6/dist-packages (from google-api-python-client>=1.2->PyDrive) (3.0.0)
Requirement already satisfied: rsa>=3.1.4 in /usr/local/lib/python3.6/dist-packages (from oauth2client>=4.0.0->PyDrive) (4.0)
Requirement already satisfied: pyasn1>=0.1.7 in /usr/local/lib/python3.6/dist-packages (from oauth2client>=4.0.0->PyDrive) (0.4.4)
Requirement already satisfied: pyasn1-modules>=0.0.5 in /usr/local/lib/python3.6/dist-packages (from oauth2client>=4.0.0->PyDrive) (0.2.2)
In [0]:
# Autorización a Google SDK para acceder a Google Drive from Colab
auth.authenticate_user()
gauth = GoogleAuth()
gauth.credentials = GoogleCredentials.get_application_default()
drive = GoogleDrive(gauth)
In [0]:
# Hace la carga de los archivos:

#   https://drive.google.com/open?id=1MruYy0JMav39Hx6HDjdcgCHaSFZ99lx- --> 
download = drive.CreateFile({'id': '1MruYy0JMav39Hx6HDjdcgCHaSFZ99lx-'})
download.GetContentFile('excluded_words.csv')

#   https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> trxpse
download = drive.CreateFile({'id': '1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt'})
download.GetContentFile('dt_trxpse_personas_2016_2018_muestra_adjt.csv')

#   https://drive.google.com/open?id=18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ --> info_pagadores (clientes)
download = drive.CreateFile({'id': '18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ'})
download.GetContentFile('dt_info_pagadores_muestra.csv')

#   https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> Archivos con las stopwords en espaƱol
download = drive.CreateFile({'id': '1Zcfgx-ak9vm6Ljzkg_A4Ms-koHxl-tgq'})
download.GetContentFile('stopwords_spanish.csv')

#   https://drive.google.com/open?id=18jlmOqyiJniFxKhaCpWDQYVQvhsYEBBZ --> Relación entre los subsectores y las categorías
download = drive.CreateFile({'id': '1VckyawOWdRsNCRv_87wSRQabZlJyoQZD'})
download.GetContentFile('dt_subsector_categoria.csv')

#   https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> Bolsa de palabras asociadas a cada categorĆ­a
download = drive.CreateFile({'id': '1K7FREJ4uU_UvvlANMh_3avvqnZNZxgwn'})
download.GetContentFile('df_bolsa_palabras.csv')

#   https://drive.google.com/open?id=1vJBm0a4q0otz7fU5Nv_pqAjGfmlTsgXt --> trx_pse ya clasificado, se utiliza para el Modelo Clasificador
download = drive.CreateFile({'id': '1AHcd_C-KD0FwFE2afjNfZ_fjOurI1NeN'})
download.GetContentFile('trx_pse_clasificado.csv')

#   https://drive.google.com/open?id=1gDbQ8CUr2so8R3LSwZJDdWiImzyf1l3d --> Imagen utilizada en el proceso
download = drive.CreateFile({'id': '1gDbQ8CUr2so8R3LSwZJDdWiImzyf1l3d'})
download.GetContentFile('265000.PNG')

#   https://drive.google.com/open?id=16mj5s7c1GLvYBav_RCxZ0C65PZrAwjA4 --> Imagen utilizada en el proceso
download = drive.CreateFile({'id': '16mj5s7c1GLvYBav_RCxZ0C65PZrAwjA4'})
download.GetContentFile('85000000.PNG')

#   https://drive.google.com/open?id=1r41zAtXO4xW4E5KdKUNkJE-g0vlCFV6r --> Mockup
download = drive.CreateFile({'id': '1r41zAtXO4xW4E5KdKUNkJE-g0vlCFV6r'})
download.GetContentFile('mock_up.jpeg')
In [26]:
# Revisa los archivos que quedaron en el directorio de trabajo
!ls
265000.PNG		       dt_trxpse_personas_2016_2018_muestra_adjt.csv
85000000.PNG		       excluded_words.csv
adc.json		       sample_data
df_bolsa_palabras.csv	       stopwords_spanish.csv
dt_info_pagadores_muestra.csv  trx_pse_clasificado.csv
dt_subsector_categoria.csv
In [0]:
# Import de las librerĆ­as base
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import EngFormatter

import seaborn as sns

%matplotlib inline
plt.style.use('seaborn-talk');

import csv
import itertools

import math
from random import sample

# Creación de función que luego se utilizarÔ para mostrar los primeros y últimos registros de un dataframe
def head_tail(n):
  return(np.r_[0:n, -n:0])
In [6]:
# Carga las stopwrods en espaƱol 
with open('stopwords_spanish.csv', 'r', encoding='latin-1') as f:
    reader = csv.reader(f)
    stopwords_spanish = list(reader)
stopwords_spanish = list(itertools.chain.from_iterable(stopwords_spanish))

print("Muestra de algunas stopwords:")
print(stopwords_spanish[0:10])
Muestra de algunas stopwords:
['a', 'a', 'acÔ', 'ademas', 'ademÔs', 'ahí', 'ajena', 'al', 'algo', 'algún']
In [7]:
# Crea el dataframe asociado a la bolsa de palabras asociada a cada categorĆ­a
df_bolsa_palabras = pd.read_csv('./df_bolsa_palabras.csv', sep=',', encoding='latin-1')

# De este dataframe extrae la lista de clasificaciones
clasificaciones = df_bolsa_palabras.columns.tolist()
clasificaciones.append('')
clasificaciones
Out[7]:
['Comida',
 'Hogar',
 'Cuidado personal',
 'Entretenimiento',
 'Educación',
 'Transporte',
 'Viajes',
 'Ahorro',
 'Pago de deudas',
 'Ingresos',
 'Retiros en efectivo',
 'Mascotas',
 'Moda',
 'TecnologĆ­a y comunicaciones',
 'Otros',
 'Gobierno e impuestos',
 'Seguros',
 'Almacenes de cadena',
 'Otros servicios financieros',
 '']
In [8]:
# Carga el archivo con la asignación de las clasificaciones objetivo para los subsectores definidos
df_subsector_categoria = pd.read_csv('./dt_subsector_categoria.csv', sep=',', encoding='latin-1')
df_subsector_categoria.iloc[head_tail(5)]
Out[8]:
subsector categoria
0 ACUEDUCTO Y ALCANTARILLADO Hogar
1 ADMINISTRACIƓN CENTRAL Gobierno e impuestos
2 AGROQUIMICOS Otros
3 ALIMENTOS CONCENTRADOS Comida
4 ARROZ Comida
44 TELEFONIA FIJA TecnologĆ­a y comunicaciones
45 TEXTILES Moda
46 TRANSPORTE AEREO Transporte
47 TRANSPORTE TERRESTRE Transporte
48 VALOR AGREGADO TecnologĆ­a y comunicaciones
In [9]:
%%time
# Carga el archivo con la información de las personas, buscando optimizar el almacenamiento con la asignación de tipo de dato.
df_pagadores = pd.read_csv('./dt_info_pagadores_muestra.csv',           
                            header=None,
                            sep=",",
                            names=['id_cliente', 'seg_str', 'ocupacion', 'tipo_vivienda', 'nivel_academico',
                                   'estado_civil', 'genero', 'edad', 'ingreso_rango'],
                            dtype={'id_cliente': 'object', 'seg_str': 'category', 
                                   'ocupacion': 'object', 'tipo_vivienda': 'object', 
                                   'nivel_academico': 'object', 'estado_civil': 'object', 
                                   'genero': 'object', 'edad': 'object', 'ingreso_rango': 'category'})
CPU times: user 362 ms, sys: 54.6 ms, total: 417 ms
Wall time: 421 ms
In [10]:
df_pagadores.iloc[head_tail(5)]
Out[10]:
id_cliente seg_str ocupacion tipo_vivienda nivel_academico estado_civil genero edad ingreso_rango
0 18 PERSONAL PLUS 5 O U M M 92 e. (4.4 5.5MM]
1 32 PERSONAL PLUS E F T M M 80 i. (8.7 Inf)
2 41 EMPRENDEDOR 3 O I W M 90 b. (1.1 2.2MM]
3 47 EMPRENDEDOR 7 NaN I I M 86 c. (2.2 3.3MM]
4 71 PERSONAL 5 O S M M 79 e. (4.4 5.5MM]
338601 338486 PERSONAL 2 NaN NaN S F 19 b. (1.1 2.2MM]
338602 338512 PERSONAL 2 NaN NaN S F 19 c. (2.2 3.3MM]
338603 338567 PERSONAL 1 NaN NaN S M 18 No disponible
338604 338578 PERSONAL 1 NaN NaN I M 18 b. (1.1 2.2MM]
338605 338594 PERSONAL 1 NaN NaN S M 18 a. (0 1.1MM]
In [11]:
%%time
# Carga el archivo con las transacciones. 
# Hay una restricción con el último campo que tiene valores separados por comas que es el mismo separador de las columnas.
# Para mitigar esto, se divide el campo en 5 posibles posiciones
# Se utiliza ademÔs la opción quoting=3 para que se ignoren las comillas pues hay unos registros que las tienen y generan problemas
# AsĆ­ mismo, a pesar de que la columna "id_trn_ach" no se usa para los modelos de ML, se mantiene para el preprocesamiento de datos

df_trxpse = pd.read_csv('./dt_trxpse_personas_2016_2018_muestra_adjt.csv',           #Para cargar como dataframe de pandas
#df_trxpse = dd.read_csv('./dt_trxpse_personas_2016_2018_muestra_adjt.csv', 
                        header=None,
                        sep=",",
                        names=['id_trn_ach', 'id_cliente', 'fecha', 'hora', 'valor_trx', 
                                'ref1', 'ref2', 'ref3',
                                'sector', 'subsector', 
                                'descripcion_p1', 'descripcion_p2', 'descripcion_p3', 'descripcion_p4', 'descripcion_p5'],
                        dtype={'id_trn_ach': 'object', 'id_cliente': 'object', 
                               'fecha': 'object', 'hora': 'object', 
                               'ref1': 'category', 'ref2': 'category', 'ref3': 'category',
                               'sector': 'category', 'subsector': 'category', 
                               'descripcion_p1': 'category', 'descripcion_p2': 'category', 
                               'descripcion_p3': 'category', 'descripcion_p4': 'category',
                               'descripcion_p5': 'category'},
                        quoting=3)  #.drop('id_trn_ach', axis=1)

# Vuelve a unificar el campo descripción, y elimina las columnas separadas que se habían creado
cols_descripcion = [col for col in df_trxpse.columns if 'descripcion' in col]
df_trxpse['descripcion'] = ''
for col in cols_descripcion:
  df_trxpse['descripcion'] = df_trxpse['descripcion'] +' '+ df_trxpse[col].astype('object')
df_trxpse = df_trxpse.drop(cols_descripcion, axis=1)

print(df_trxpse.shape)
(11866506, 11)
CPU times: user 50.9 s, sys: 4.3 s, total: 55.2 s
Wall time: 55.2 s
In [12]:
# FUnción para validar la cantidad de registros del archivo original
def file_len(fname):
    with open(fname) as f:
        for i, l in enumerate(f):
            pass
    return i + 1
  
registros = file_len('./dt_trxpse_personas_2016_2018_muestra_adjt.csv')
print(f'El archivo original contiene {registros} registros')
El archivo original contiene 11866544 registros
In [13]:
# Debido al tamaƱo del set de tados, revisa el uso de memoria asociado
df_trxpse.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 11866506 entries, 0 to 11866505
Data columns (total 11 columns):
id_trn_ach     object
id_cliente     object
fecha          object
hora           object
valor_trx      object
ref1           category
ref2           category
ref3           category
sector         category
subsector      category
descripcion    object
dtypes: category(5), object(6)
memory usage: 4.1 GB

Se identifica que el dataframe estÔ ocupando alrededor de 4.1GB, con los siguientes pasos se busca tanto hacer una preparación inicial de los datos, como disminuir el consumo de almacenamiento para optimizar el uso de la memoria disponible (12GB).

In [14]:
# Hace una exploración del dataframe resultante
df_trxpse.iloc[head_tail(5)]
Out[14]:
id_trn_ach id_cliente fecha hora valor_trx ref1 ref2 ref3 sector subsector descripcion
0 230435642 3 20161207 113451 2122392.51 CC NaN NaN \N \N NaN
1 222356110 10 20161016 3424 148438.37 Referencia: Contrato: Valor: CC NaN \N \N NaN
2 309137749 10 20180120 195042 94025.19 CC NaN NaN \N \N NaN
3 324614737 10 20180326 192146 94430.07000000001 CC NaN NaN \N \N NaN
4 235344690 18 20170106 201317 670645.5699999999 MEDICINA PREPAGADA COLSANITAS CE NaN \N \N NaN
11866501 295760261 338583 20171120 205011 104534.5 ETicket Avianca TIZI ETicket Avianca TIZI NaN \N \N NaN
11866502 217569480 338598 20160914 161553 11857301.4 NaN NaN NaN \N \N NaN
11866503 223092204 338599 20161021 120320 247517.45 CC NaN NaN \N \N NaN
11866504 253494650 338599 20170418 152936 296533.93 CC NaN NaN \N \N NaN
11866505 261904720 338599 20170602 154947 363052.84 CC NaN NaN \N \N NaN

Se encuentra en esta primera exploración que para los campos de texto asociados a las referencias, y a la categorización del receptor hay múltiples campos con valores NaN o valores \N, por lo cual, antes de continuar con la exploración, y con el fin de tener un dataset mucho mÔs limpio, se procederÔ cambiar estos valores por cadenas vacías. Así mismo, se procederÔ a realizar una limpieza general del texto mediante procesos como:

  • Convertir todas las cadenas a minĆŗsculas
  • Cambiar caracteres especiales por espacios
  • Volver a convertir las cadenas a tipo "category"
  • Cambiar las letras con tĆ­ldes y otros acentos, por la letra base

Para esto se crea una función, de tal forma que pueda ser luego utilizadas en todos los sets de palabras que se utilicen en el ejercicio.

In [0]:
# Crea la función para procesar el texto de los dataframes
def organiza_texto_df(df_proceso, columnas='', type_category=False):
  
  # Si no se pasó un vector de columnas, crea un vector con todas las columnas del dataframe
  if len(columnas) == 0:
    
    columnas = df_proceso.columns.values
    
  for col in columnas:

    # Si se pasó la bandera de que se debe trabajar con dtype = category, hace el procesamiento teniendo en cuenta esto (category -> object -> category)
    if type_category:
        
      df_proceso[col] = df_proceso[col].astype('object').fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
                                                  str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ').astype('category')
        
    # De lo contrario, trabaja directamente con la columna teniendo en cuenta que es string
    else:

      df_proceso[col] = df_proceso[col].fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
                                                  str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ')
      
    print(f'Columna {col} procesada')
    
  #Devuelve el dataframe prosesado
  return(df_proceso)
    
    
In [16]:
%%time
# Genera un arreglo con los nombres de los campos de texto que describen la transacción
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']

# Organiza el texto de las columnas con la caracterización de las transacciones
df_trxpse = organiza_texto_df(df_trxpse, cols_texto, True)
Columna ref1 procesada
Columna ref2 procesada
Columna ref3 procesada
Columna sector procesada
Columna subsector procesada
Columna descripcion procesada
CPU times: user 3min 36s, sys: 3.41 s, total: 3min 40s
Wall time: 3min 40s
In [17]:
# Hace una exploración del dataframe resultante
df_trxpse.iloc[head_tail(5)]
Out[17]:
id_trn_ach id_cliente fecha hora valor_trx ref1 ref2 ref3 sector subsector descripcion
0 230435642 3 20161207 113451 2122392.51 cc
1 222356110 10 20161016 3424 148438.37 referencia contrato valor cc
2 309137749 10 20180120 195042 94025.19 cc
3 324614737 10 20180326 192146 94430.07000000001 cc
4 235344690 18 20170106 201317 670645.5699999999 medicina prepagada colsanitas ce
11866501 295760261 338583 20171120 205011 104534.5 eticket avianca tizi eticket avianca tizi
11866502 217569480 338598 20160914 161553 11857301.4
11866503 223092204 338599 20161021 120320 247517.45 cc
11866504 253494650 338599 20170418 152936 296533.93 cc
11866505 261904720 338599 20170602 154947 363052.84 cc
In [18]:
# Se hace un describe de las 6 columnas de texto que categorizan la transacción
df_trxpse[cols_texto].describe()
Out[18]:
ref1 ref2 ref3 sector subsector descripcion
count 11866506 11866506 11866506 11866506 11866506 11866506
unique 476162 169384 1 11 55 3
top cc
freq 1154116 5078404 11866506 8559121 8559121 11866212

Teniendo presente que "ref1", "ref2", "ref3", "sector", "subsector" y "descripcion" tienen una cantidad muy baja de valores únicos comparados con la cantidad de registros, se procede a mantener como tipo "category" para optimizar el uso de memoria. MÔs adelante se harÔn otros procesamientos con estos campos, pero siempre se buscarÔ volverlos a llevar a category para mantener un uso controlado de memoria y poder utilizarla para los prcesamientos pesados.

Se encuentra ademƔs que ref3 estƔ vacƭa para todos los registros, pero por el momento se mantiene para que el modelo siga aplicando a pesar de que a futuro lleguen transacciones que tengan valores en este campo.

In [19]:
# Presenta nuevamente la cantidad de memoria utilizada
df_trxpse.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 11866506 entries, 0 to 11866505
Data columns (total 11 columns):
id_trn_ach     object
id_cliente     object
fecha          object
hora           object
valor_trx      object
ref1           category
ref2           category
ref3           category
sector         category
subsector      category
descripcion    category
dtypes: category(6), object(5)
memory usage: 3.8 GB

Una vez realizado un ajuste inicial a los valores de los campos de texto, se procede a hacer una conversión de las columnas de fecha y hora para pasarlos de tipo "object" a tipo datetime.

In [20]:
# Primero se exploran los valores nulos y atĆ­picos en este campo
print(f"Se encuentran {df_trxpse[df_trxpse['fecha'].isna()].shape[0]} registros con valor de fecha nulo")
Se encuentran 12724 registros con valor de fecha nulo
In [21]:
# Luego se valida la información disponible para los registros con fecha nula, comenzando por los valores de transacción asociados
print(df_trxpse.loc[df_trxpse['fecha'].isna(), 'valor_trx'].value_counts(dropna=False))
\N     12127
NaN      597
Name: valor_trx, dtype: int64

Se encuentra que las filas que tienen valor de fecha nulo, tambiƩn tienen inconvenientes en el campo valor_trx pues este es NaN o tiene el valor de \N que no corresponde al tipo de dato que debe tener este campo. Sin embargo, se procede tambiƩn a revisar los valores de las columnas de texto para estos mismos registros.

In [22]:
df_trxpse[df_trxpse['fecha'].isna()].groupby(cols_texto).size().reset_index(name='counter')
Out[22]:
ref1 ref2 ref3 sector subsector descripcion counter
0 12724

Se encuentra que para todos estos registros los campos de texto que describen la transacción también estÔn vacíos, por lo cual se confirma que se puede proceder a eliminarlos.

In [23]:
# Se eliminan los registros de hora \\N y NaN. 
df_trxpse = df_trxpse[df_trxpse['fecha'].notna()]

# Se eliminan los registros de fecha con NaN
df_trxpse.shape
Out[23]:
(11853782, 11)

Teniendo ya depurado el campo 'fecha', se procede a ajustar el campo 'hora' pues se encuentra que no todos los valores son de 6 dígitos como debería ser pues su formato se indica como HHMMSS. Esto suele pasar cuando este tipo de valores pasan a ser asignados a un campo numérico y los que comienzan por 0 pierden caracteres, por ejemplo, las 000102 que corresponde a las 00 de la mañana, con 1 minuto y 2 segundos se convierte en 102. Para mitigar esto se procede a agregar ceros a la izquiera para complementar la información y asegurar que todos los valores en este campo queden de 6 dígitos.

Posteriormente, se procede a contactenar la fecha y hora en un solo string, y este se convierte en fecha.

Finalmente, para tener mayor flexibilidad con la información al momento de hacer los diferentes procesamientos, se crean cuatro nuevas columnas:

  • Year
  • Month
  • DayOfMonth
  • DayOfWeek
  • Hour
In [0]:
# Ajusta el campo hora para que quede con 6 dĆ­gitoos
df_trxpse['hora'] = df_trxpse['hora'].str.zfill(6)

# Genera el campo 'Datetime' con la fecha y la hora en el formato correcto
df_trxpse['DateTime'] = df_trxpse['fecha'].astype('int64').astype(str)+df_trxpse['hora']
df_trxpse['DateTime'] = pd.to_datetime(df_trxpse['DateTime'],format='%Y%m%d%H%M%S')

# Agrega las nuevas columnas calculadas, indicando el tipo de dato asociado
df_trxpse['Year'] = df_trxpse['DateTime'].dt.year.astype('int16')                 # year
df_trxpse['Month'] = df_trxpse['DateTime'].dt.month.astype('int8')                # month
df_trxpse['DayOfMonth'] = df_trxpse['DateTime'].dt.day.astype('int8')             # day of week
df_trxpse['DayOfWeek'] = df_trxpse['DateTime'].dt.weekday_name.astype('category') # day of week
df_trxpse['Hour'] = df_trxpse['DateTime'].dt.hour.astype('int8')                  # hour
df_trxpse.drop(['fecha','hora'],axis=1,inplace=True)
In [25]:
df_trxpse.iloc[head_tail(5)]
Out[25]:
id_trn_ach id_cliente valor_trx ref1 ref2 ref3 sector subsector descripcion DateTime Year Month DayOfMonth DayOfWeek Hour
0 230435642 3 2122392.51 cc 2016-12-07 11:34:51 2016 12 7 Wednesday 11
1 222356110 10 148438.37 referencia contrato valor cc 2016-10-16 00:34:24 2016 10 16 Sunday 0
2 309137749 10 94025.19 cc 2018-01-20 19:50:42 2018 1 20 Saturday 19
3 324614737 10 94430.07000000001 cc 2018-03-26 19:21:46 2018 3 26 Monday 19
4 235344690 18 670645.5699999999 medicina prepagada colsanitas ce 2017-01-06 20:13:17 2017 1 6 Friday 20
11866501 295760261 338583 104534.5 eticket avianca tizi eticket avianca tizi 2017-11-20 20:50:11 2017 11 20 Monday 20
11866502 217569480 338598 11857301.4 2016-09-14 16:15:53 2016 9 14 Wednesday 16
11866503 223092204 338599 247517.45 cc 2016-10-21 12:03:20 2016 10 21 Friday 12
11866504 253494650 338599 296533.93 cc 2017-04-18 15:29:36 2017 4 18 Tuesday 15
11866505 261904720 338599 363052.84 cc 2017-06-02 15:49:47 2017 6 2 Friday 15

Con lo anterior ya todas las columnas tienen el tipo adecuado de dato, salvo la asociada a 'valor_trx' que debería ser numérica, por lo cual se procede a explorarla, ajustarla y convertirla. Cabe resaltar que esta se había cargado en principio como "object" debido a que tenían valores NaN y "/N" que fueron depurados con la eliminación de registros que se hizo a partir de los que tenían campo fecha = NaN

In [26]:
# Hace un describe de los valores actuales de la columna 'valor_trx'
df_trxpse['valor_trx'].describe()
Out[26]:
count     11853782
unique    10069784
top           0.99
freq           155
Name: valor_trx, dtype: object
In [0]:
# Convierte la columna valor_trx a numƩrico
df_trxpse['valor_trx'] = pd.to_numeric(df_trxpse['valor_trx'])
In [28]:
# Hace un describe de los nuevos valores de la columna 'valor_trx'
df_trxpse['valor_trx'].describe().apply(lambda x: format(x, 'f'))
Out[28]:
count      11853782.000000
mean         366851.577509
std         1595361.915362
min               0.010000
25%           52026.562500
50%          126148.925000
75%          301706.700000
max      1788605269.620000
Name: valor_trx, dtype: object
In [29]:
print(f"Hay {df_trxpse[df_trxpse['valor_trx']<10].shape[0]} registros con un valor menor a $10")
print(f"Hay {df_trxpse[df_trxpse['valor_trx']>100000000].shape[0]} registros con un valor mayor a $100 millones")
Hay 2089 registros con un valor menor a $10
Hay 176 registros con un valor mayor a $100 millones

Se encuentra que ya la columna es de tipo numérica, aunque hay un outlier pues el valor mÔximo de una transacción fue de 1.788 millones, lo cual es ilógico para una transacción de PSE. En la parte de limpieza se procederÔ a definir el tratamiento a dar a este valor.

Adicionalmente, se procede a eliminar la columna "id_trn_ach" que no es Ćŗtil para el ejercicio, y se crea una nueva columna de tipo categórico con una cadena vacĆ­a que indica la categorĆ­a asociada a la transacción. Ɖsta se irĆ” pobando en la medida en que logre ser relacionada a travĆ©s de los mĆ©todos que se desarrollan en las siguientes secciones.

In [0]:
# Genera la nueva columna con el listado de clasificaciones
df_trxpse['categoria'] = pd.Categorical(["" for x in range(len(df_trxpse.index.values))], 
                                        categories=clasificaciones,
                                        ordered=False)

Finalmente, se procede a hacer una exploración final del tipo de datos definitivo para cada columna, y la cantidad de memoria utilizada.

In [31]:
# Presenta nuevamente la cantidad de memoria utilizada
df_trxpse.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'>
Int64Index: 11853782 entries, 0 to 11866505
Data columns (total 16 columns):
id_trn_ach     object
id_cliente     object
valor_trx      float64
ref1           category
ref2           category
ref3           category
sector         category
subsector      category
descripcion    category
DateTime       datetime64[ns]
Year           int16
Month          int8
DayOfMonth     int8
DayOfWeek      category
Hour           int8
categoria      category
dtypes: category(8), datetime64[ns](1), float64(1), int16(1), int8(3), object(2)
memory usage: 2.0 GB
In [32]:
import gc
gc.collect()
Out[32]:
69

Se encuentra que con los diferentes procesamientos y optimizaciones de memoria, se ha logrado llevar el tamaño en memoria del DataFrame de 4.1GB a solo 2.0GB, logrando una optimización de casi el 52% de su tamañoa pesar de haber agregado varias nuevas categorías.

FInalmente, se hace a continuación un procesamiento de la información asociada a las stopwords, que también involucra texto, para que se aplique el mismos proceso de limpieza que se sguió con el detaframe de las transacciones.

In [33]:
# Hace el procesamiento del arreglo con las stopwords
df_stopwords = pd.DataFrame(stopwords_spanish, columns=['stop_words'])
df_stopwords = organiza_texto_df(df_stopwords)
stopwords_spanish = df_stopwords['stop_words'].tolist()

print("Muestra de algunas stopwords:")
print(stopwords_spanish[0:10])
Columna stop_words procesada
Muestra de algunas stopwords:
['a', 'a', 'aca', 'ademas', 'ademas', 'ahi', 'ajena', 'al', 'algo', 'algun']

Clasificación de las categorías de las transacciones

Para esta clasificación se parte del nuevo dataframe de las transacciones df_trxpse y sobre este se hacen varios procesos:

  1. Manualmente se asigna una categorĆ­a para aquellas transacciones en las que hay un valor de sector y subsector que estĆ” claramente definido y se puede asignar a una de las 15 categorĆ­as propuestas.
  2. Para el resto de los registros, se harÔ un proceso de minería de texto mediante el cual se concatenen los 6 campos de definición de la transacción, y luego se realice un proceso de preparación y posterior procesamiento que incluye aplicar bolsas de palabras obtenidas mediante Web Scraping y otras generadas por concenso del equipo, así como generar clusters sobre los cuales se haga una revisión de térmionos para clasificarlos en las categorías propuestas.
In [34]:
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo
df_texto = pd.DataFrame(pd.Series(df_trxpse[cols_texto].values.tolist()).str.join(' '), columns=['texto'])
df_texto['longitud']  = df_texto['texto'].str.len()
df_texto['num_palabras']  = df_texto['texto'].str.split().str.len()

# Hace una exploración de los primeros registros
df_texto.iloc[head_tail(5)]
Out[34]:
texto longitud num_palabras
0 cc 7 1
1 referencia contrato valor cc 37 4
2 cc 7 1
3 cc 7 1
4 medicina prepagada colsanitas ce 36 4
11853777 eticket avianca tizi eticket avianca tizi 45 6
11853778 5 0
11853779 cc 7 1
11853780 cc 7 1
11853781 cc 7 1
In [35]:
df_texto.shape
Out[35]:
(11853782, 3)
In [36]:
# Grafica la longitud de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))

df_texto['longitud'].plot.box(vert=False);
plt.title("Longitud de los campos descriptores del texto");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Caracteres', ylabel='');
In [37]:
# Grafica la cantidad de palabras de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))

df_texto['num_palabras'].plot.box(vert=False);
plt.title("Cantidad de palabras de los campos descriptores del texto");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='NĆŗmero de Palabras', ylabel='');

Se encuentra hasta este punto que el nuevo dataframe creado tiene los mismos registros del dataframe de transacciones original, pero condensa los 6 campos descriptores de la transacción en uno solo.

Se encuentra tambiƩn que este nuevo campo tiene una longitud de 7 a mƔs de 7 caracteres, los cuales se distribuyen en una cantidad de 0 a 15 palabras. Cabe resaltar que aquellos campos sin palabras tienen 7 caracteres asociados a los espacios que se utilizaron para separar los campos que habƭa en el DataFrame Original.

COmo parte del procesamiento serÔ necesario, mÔs adelante, dejar estos registros con la clasificación de "Otros", pues el no tener texto clasificador hace muy dificil relacionarlo con una de las categorías objetivo.

Exploración Inicial

En este caso se utilizarÔ Scikit Learn para hacer un procesamiento del texto, entender su detalle, definir cómo se prepararÔ y luego aplicar métodos no supervisados como apoyo para asignar las categorías objetivo.

In [38]:
# Para hacer el procesamiento de texto
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfVectorizer

# Para el proceso de clusters
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_samples, silhouette_score

# Para presentar el wordcloud de las palabras
!pip install wordcloud
from wordcloud import WordCloud, ImageColorGenerator
Requirement already satisfied: wordcloud in /usr/local/lib/python3.6/dist-packages (1.5.0)
Requirement already satisfied: pillow in /usr/local/lib/python3.6/dist-packages (from wordcloud) (4.0.0)
Requirement already satisfied: numpy>=1.6.1 in /usr/local/lib/python3.6/dist-packages (from wordcloud) (1.14.6)
Requirement already satisfied: olefile in /usr/local/lib/python3.6/dist-packages (from pillow->wordcloud) (0.46)

Para la exploración inicial del texto se utilizarÔ una matriz TF (Term Frecuency) que permita identificar cuÔles son los términos mÔs y menos frecuentes, y a partir de ello hacer un proceso de preparación de los datos antes de proceder con la matriz TF-IDF (Term Frecuency-Inverse Document Frecuency) que serÔ a partir de la cual se creen los clusters.

In [0]:
# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones, excluyendo solo stopwords
vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=stopwords_spanish)
X_tf = vectorizer.fit_transform(df_texto['texto'])

# Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
words = np.array(vectorizer.get_feature_names())
counts = X_tf.sum(axis=0).A1

# Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)
In [84]:
umbral_low_frec = df_trxpse.shape[0]*0.001
umbral_unique_words = 100
umbral_min_chars =2

low_frec_words = all_words[all_words['frecuencia'] <= umbral_low_frec].index.values
unique_words = all_words[all_words['frecuencia'] <= umbral_unique_words].index.values
two_char_words = all_words[np.vectorize(len)(all_words.index.values) <= umbral_min_chars].index.values

print(f"El total de palabras identificadas en los textos eliminando las stopwords son {all_words.shape[0]}")
print(f"De estas, solo aparecen hasta {umbral_unique_words} veces un total de {all_words[all_words['frecuencia'] <= umbral_unique_words].shape[0]} palabras (Ćŗnicas)")
print(f"Y aparecen mƔximo {umbral_low_frec} veces ({umbral_low_frec*100/df_trxpse.shape[0]}%) un total de {all_words[all_words['frecuencia']<=umbral_low_frec].shape[0]} palabras (poco frecuentes)")
print(f"AsĆ­ mismo, hay un total de {len(two_char_words)} que tienen hasta {umbral_min_chars} caracteres")
El total de palabras identificadas en los textos eliminando las stopwords son 186
De estas, solo aparecen hasta 100 veces un total de 175 palabras (Ćŗnicas)
Y aparecen mƔximo 11853.782000000001 veces (0.10000000000000002%) un total de 186 palabras (poco frecuentes)
AsĆ­ mismo, hay un total de 0 que tienen hasta 2 caracteres
In [41]:
all_words.iloc[head_tail(5)]
Out[41]:
frecuencia
cc 5950210
pago 4321194
servicios 1338152
medios 1175101
factura 1174247
lytsd 1
lytujn 1
lytupa 1
lytva 1
zzzzta 1

Hasta este punto se tiene el arreglo con la frecuencia de cada una de las palabras en todo el set de datos, y otros subsets asociados a:

  • Palabras Ćŗnicas: aparecen mĆ”ximo 100 veces en los 12M de registros.
  • Palabras poco frecuentes: aparecen mĆ”ximo en el 0.1% de los 12M de registros.
  • Palabras cortas: que son las que tienen mĆ”ximo 2 caracteres

Estos sets de palabras se utilizarƔn en el procesamiento del texto

A continuación se procede a hacer una exploración visual de los términos mÔs frecuentes.

In [42]:
# Genera la grƔfica con las 25 palabras mƔs frecuentes en las descripciones de todas las transacciones
all_words.head(25).plot.barh(title='Top 25 Palabras mƔs Frecuentes');
plt.gca().invert_yaxis();
In [43]:
# Genera un wordcloud con las palabras mƔs frecuentes
wordcloud = WordCloud(width=800,
                      height=400,
                      min_font_size=8,
                      max_font_size=60,
                      relative_scaling=0.3,
                      background_color="white").generate_from_frequencies(all_words['frecuencia'].head(200).to_dict())

# Display the generated image:
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()

Se encuentra en el wordcloud que hay múltiples palabras que no agregan valor para el ejercicio, como por ejemplo: CC, pago, pagos, no, referencia, pse, web, cr, paymentid, entre otras. Para facilitar este ejercicio se procede a hacer una revisión de las palabras mÔs frecuentes y así generar manualmente un listado de palabras a excluir del ejercicion.

In [44]:
# NĆŗmero de columnas y filas en la tabla a presentar
n_cols = 10
n_rows = 30

# presentación de una matriz con las palabras mÔs frecuentes, para identificar términos a eliminar por no aportarle al ejercicio
pd.DataFrame(all_words.head(n_rows*n_cols).index.values.reshape(n_rows, n_cols))
Out[44]:
0 1 2 3 4 5 6 7 8 9
0 cc pago servicios medios factura comunicacion financieros bancos no idc
1 pse fija telefonia recarga agregado compra saldo gobierno medellin referencia
2 empresas express esp pagos naturales recursos electricidad publicas credito transaccion
3 certificados tpni administracion central postpago generacion contrato libertad tradicion cpv
4 nit facturas ref ni nequi tipificado tarjeta recaudo avianca eticket
5 portal falabella exito cartera impuesto payu cmr electronico contractid addreess
6 transaccional visa tiquete hogar multiplay linea web vehiculos comercio cl
7 predial fc flight date psepayment cr seguros municipios suramericana bogota
8 cuenta servicio camara booking emcaliach acces null presentacion online periodo
9 etb virtuales tienda cupon asociado natural gas colombia cuota amex
10 bancoomevapfa obligaciones interior obligacion sa banco reserva plan virgin sas
11 ce colegio unificado sitio irecaudo master educativos card soluciones paymentid
12 tradicional tiquetes derechos claro cedula moviles polizas via inscripcion occidente
13 automotores boleteria sc miavantel movil virtual cajas financiadas compensacion establecimientos
14 numero personas safetypay mes pas sur duplicado facturasptp salud municipio
15 otro pension prepagada prepago alkosto antioquia recargas flytech soat examen
16 apostilla facturapayu consulta prestamo arrendamiento colsanitas generales recaudos hipotecario matricula
17 certificado total licencia conduccion orden paquetes canon construccion aportes app
18 tasa variable aseo vida tag eps finesa acueducto universidad consumo
19 internet pasarela indice propietarios rebaja off novaventa id citibank colpatria
20 familiar documentos medicina bog colaborador antiplan mastercard agrupa plataforma facturacion
21 certificadosvirtuales snrpse otros usuario cuentas obras infraestructura peaje electronicos mb
22 beneficencia septiembre transporte aya eab cobro proceso travel changebooking price
23 valorizacion herbalife pedido payment industria mapfre bolivar lineru pregrado davivienda
24 ecommerce chevyplan conj agosto unico placa junio mayo poliza customer
25 abril metro yanbal realizados autos productos exitocom comfama order julio
26 marzo ahorro voluntario aporte compensar codensa ciudadania noviembre complementario eleccion
27 recargasptp retail febrero exterior octubre oro recibo abono curso celular
28 aco fonvalmed oriflame res publicos caribe vehicular boc registro paseya
29 inmobiliaria android sabaneta concepto gases san transito jad alcaldia kr

A partir de esta exploración se define que se deben eliminar también las siguientes palabras que se cargan en el siguiente vector y que no se consideran relevantes para el ejercicio:

In [0]:
no_relevant_words = ['cc', 'cƩdula', 'cedula', 'pago', 'pagos', 'no', 'ni', 'null', 'id', 'pse', 'nit', 'online', 'ce', 'sa', 'sas', 'tpni',
                     'payu', 'paymentid', 'psepayment', 'payment', 'facturapayu', 'factura', 'contrato', 'contratos', 'referencia',
                     'mes', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre', 
                     'otro', 'otros', 'idc', 'tipificado', 'ciudadania',
                     'hotmailcom', 'gmailcom']

Asignación de categoría para registros sin información

Para esta etapa del procesamiento se harÔ una identificación de aquellos registros que estÔn vacíos o solo tienen palabras que se han identificado como no requeridas (stopwords_spanish, no_relevant_cords, unique_words, two_char_words), de tal forma que a todos ellos se les asigne la categoría objetivo "Otros", pues no se cuenta con información suficiente para hacer la clasificación.

In [0]:
# Genera el set de palabras a excluir
excluded_words = stopwords_spanish + no_relevant_words + unique_words.tolist() + two_char_words.tolist()

# Obtiene los registros que tienen no tienen palabras o solo tienen las excluidas => Sobre X_tf extrae todas las filas, pero solo columnas solo diferentes a las "excluded_words"
filas_clasificacion = X_tf[:, np.isin(words, excluded_words, invert=True)].sum(axis=1).A1 == 0

# Para las filas identificadas, asigna la categoría "Otros" pues por falta de información no es posible clasificar el registro
df_trxpse.loc[filas_clasificacion, 'categoria'] = 'Otros'
In [47]:
df_trxpse['categoria'].value_counts()
Out[47]:
                               8643570
Otros                          3210212
Pago de deudas                       0
Hogar                                0
Cuidado personal                     0
Entretenimiento                      0
Educación                            0
Transporte                           0
Viajes                               0
Ahorro                               0
Ingresos                             0
Otros servicios financieros          0
Retiros en efectivo                  0
Mascotas                             0
Moda                                 0
TecnologĆ­a y comunicaciones          0
Gobierno e impuestos                 0
Seguros                              0
Almacenes de cadena                  0
Comida                               0
Name: categoria, dtype: int64
In [48]:
fig, ax = plt.subplots()

sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Se encuentra hasta este punto que hay cerca de 2.5 millones de transacciones que no tienen información suficiente para ser clasificadas, pues sus 6 textos de referencia tienen una de las siguientes restricciones:

  • EstĆ”n vacĆ­os
  • Contienen palabras que se repiten muy pocas veces en todo el set de datos (mĆ”ximo 100 veces en los 12 millones de transacciones), o que tienen 2 o menos caracteres, o son irrelevantes para el ejercicio.

Asignación de categoría por subsector

Teniendo presente que muchas de las transacciones tienen ya asignado un subsector, pues el receptor del pago es cliente del banco, se hizo una clasificación manual de a qué categoría debería pertenecer ese subsector, y por ello en esta etapa del procesamiento se procederÔ a asignar esa categoría a las transacciones asociadas.

In [49]:
# Hace una preparación de los datos del dataframe df_subsector_categoria, bajo los mismos criterios que el resto de textos
df_subsector_categoria = organiza_texto_df(df_subsector_categoria, ['subsector'])

# Hace un loop para asignar a los respectivos registros la categorĆ­a asociada
for ind, fila in df_subsector_categoria.iterrows():
  df_trxpse.loc[df_trxpse['subsector'] == fila['subsector'], ['categoria']] = fila['categoria']
Columna subsector procesada
In [50]:
fig, ax = plt.subplots()

sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Asignación de categoría partir de bolsa de palabras por Web Scraping

Con la intención de tener una fuente para hacer la clasificación de las transacciones, se realizó Web Scraping a la pÔgina de PSE, en donde se encuentra el listado de empresas que utilizando la plataforma, relacionando una categoría para cada una, por lo cual se obtuvo esta información y luego se mapeó con las categorías utilizadas como parte del anÔlisis.

In [0]:
# Hace import de las librerĆ­as para hacer Web Scrapping y limpiar el texto
import requests
from bs4 import BeautifulSoup
import re
import pandas as pd
from collections import OrderedDict
In [0]:
page_pse = requests.get("https://portal.psepagos.com.co/web/catalogo-pse?utm_campaign=catalogopse&utm_source=url_pse&utm_medium=pse")
soup_pse = BeautifulSoup(page_pse.content, 'html.parser')
html_pse = list(soup_pse.children)

Analizando el HTML de la pÔgina web , se encuentra que existen un campo llamado span en el que en algunas ocasiones es el mismo nombre de la empresa, sin embargo, se encuentra que para otras empresas es un campo con una serie de palabras en relación al tipo de empresa.

Se encuentran ademas que existen unas categorias que pueden asociarse directamente las categorias MCC (las objetivo en este ejercicio).

In [0]:
# Carga la información de las 3 etiquetas de interés
empresas = html_pse[2].find_all(class_="pse-banks__trade__title")
span = html_pse[2].find_all(style="display: none;")
# Código de categoria
categoria = html_pse[2].find_all(class_="pse-banks__trade-pagar-btn")
In [0]:
# Genera un dataframe con la información de interés de PSE
df_pse = pd.DataFrame({'Empresa': empresas,'Span': span,'Categoria_numero': categoria})
In [55]:
# Categorias de PSE por el HTML , mƔs unas adicionadas manualmente
pse_categorias = {'1425': 'Turismo','1413': 'Vivienda y Construcción','1418': 'Vivienda y Construcción','1427': 'Comercio','1428': 'Educación',
                  '1429':'Entretenimiento','1444': 'Financiero','1409': 'Educación','1481':'Fondo de Empleados', '1412':'Vehiculos', '1415':'Fundaciones',
                  '1433':'Gobierno','1434':'Salud','1435':'Otros Servicios', '1467':'servicios publicos', '1430':'Caja de Compensación', '1431':'Fundaciones',
                  '1439':'Transporte','1440':'Vivienda','1448':'Portal de Pagos Electrónicos - Banco de BogotÔ', '1454':'Fondo de Empleados',
                  '1449':'Multipagos PSE - Bancolombia','1450':'Centro de Pago PSE - Banco de Occidente','1464':'Gobierno','1478':'Gobierno',
                  '1465':'TecnologĆ­a y Comunicaciones','1466':'Financiero','1467':'Servicios PĆŗblicos y TV Por Cable'}
pse_categorias
Out[55]:
{'1409': 'Educación',
 '1412': 'Vehiculos',
 '1413': 'Vivienda y Construcción',
 '1415': 'Fundaciones',
 '1418': 'Vivienda y Construcción',
 '1425': 'Turismo',
 '1427': 'Comercio',
 '1428': 'Educación',
 '1429': 'Entretenimiento',
 '1430': 'Caja de Compensación',
 '1431': 'Fundaciones',
 '1433': 'Gobierno',
 '1434': 'Salud',
 '1435': 'Otros Servicios',
 '1439': 'Transporte',
 '1440': 'Vivienda',
 '1444': 'Financiero',
 '1448': 'Portal de Pagos Electrónicos - Banco de BogotÔ',
 '1449': 'Multipagos PSE - Bancolombia',
 '1450': 'Centro de Pago PSE - Banco de Occidente',
 '1454': 'Fondo de Empleados',
 '1464': 'Gobierno',
 '1465': 'TecnologĆ­a y Comunicaciones',
 '1466': 'Financiero',
 '1467': 'Servicios PĆŗblicos y TV Por Cable',
 '1478': 'Gobierno',
 '1481': 'Fondo de Empleados'}
In [0]:
# Funciones para "limpiar" el texto obtenido mediante Web Scrapping
def empresa_extract(empresa):
    pattern ='^<div class="pse-banks__trade__title">(.*)</div>'
    clean_empresa=re.findall(pattern, str(empresa))
    clean_empresa=re.sub("[^a-zA-Z-0-9]", " ", str(clean_empresa)) 
    return clean_empresa

def span_extract(span):
    pattern ='^<div style="display: none;">\n<span>(.*)</span>\n</div>'
    clean_span=re.findall(pattern, str(span))
    clean_span=re.sub("[^a-zA-Z-0-9]", " ", str(clean_span)) 
    return clean_span

def categoria_extract(cat):
    pattern ='^<div class="pse-banks__trade-pagar-btn" id="btnpse_([0-9]*).*'
    clean_cat=re.findall(pattern, str(cat))
    return clean_cat[0]

def categoria(cat):
    if cat in pse_categorias.keys():
        return pse_categorias[cat]
    else:
        return 'Otros Servicios'
In [0]:
# Procede a limpiar los textos
df_pse['Empresa']=df_pse['Empresa'].apply(empresa_extract)
df_pse['Span']=df_pse['Span'].apply(span_extract)
df_pse['Categoria_numero']=df_pse['Categoria_numero'].apply(categoria_extract)
df_pse['Categoria']=df_pse['Categoria_numero'].apply(categoria)
In [58]:
# Hace una exploración de DataFrame Resultante
df_pse.iloc[head_tail(5)]
Out[58]:
Categoria_numero Empresa Span Categoria
0 1444 BANCO DE OCCIDENTE banco de occidente Financiero
1 1444 FIDUCIARIA CFC deshabilitada fiduciaria cfc Financiero
2 1444 LIBERTY SEGUROS DE VIDA liberty seguros de vida Financiero
3 1433 MUNICIPIO DEL CARMEN municipio del carmen Gobierno
4 1433 AGENCIA NACIONAL DE MINERIA agencia nacional de mineria Gobierno
3423 1478 MUNICIPIO DEL PATIA municipio del patia Gobierno
3424 1405 OBTENGA MAS SAS obtenga mas sas Otros Servicios
3425 1453 PA ALES EN TU CASA SAS pa ales en tu casa sas Otros Servicios
3426 1448 PORTAL DE PAGOS ELECTR NICOS portal de pagos electr nicos Portal de Pagos Electrónicos - Banco de BogotÔ
3427 1455 empresa26 courier casilleros virtuales ci tecnologia a... Otros Servicios
In [59]:
# Valida la cantidad de registros por cada categorĆ­a
df_pse['Categoria'].value_counts()
Out[59]:
Otros Servicios                                   840
Comercio                                          753
Educación                                         440
Gobierno                                          291
Vivienda                                          221
Financiero                                        200
Entretenimiento                                   155
Transporte                                        108
TecnologĆ­a y Comunicaciones                        99
Salud                                              77
Vivienda y Construcción                            66
Servicios PĆŗblicos y TV Por Cable                  44
Caja de Compensación                               39
Turismo                                            33
Fundaciones                                        30
Fondo de Empleados                                 25
Vehiculos                                           4
Centro de Pago PSE - Banco de Occidente             1
Portal de Pagos Electrónicos - Banco de BogotÔ      1
Multipagos PSE - Bancolombia                        1
Name: Categoria, dtype: int64
In [60]:
# Crea un dataframe que por cada categoría contiene tanto los nombres de las empresas, como las palabras de la sección Span
df_pse = df_pse[['Categoria', 'Empresa']].append(df_pse[['Categoria', 'Span']]).reset_index(drop=True)

df_pse.iloc[head_tail(5)]
Out[60]:
Categoria Empresa Span
0 Financiero BANCO DE OCCIDENTE NaN
1 Financiero FIDUCIARIA CFC deshabilitada NaN
2 Financiero LIBERTY SEGUROS DE VIDA NaN
3 Gobierno MUNICIPIO DEL CARMEN NaN
4 Gobierno AGENCIA NACIONAL DE MINERIA NaN
6851 Gobierno NaN municipio del patia
6852 Otros Servicios NaN obtenga mas sas
6853 Otros Servicios NaN pa ales en tu casa sas
6854 Portal de Pagos Electrónicos - Banco de BogotÔ NaN portal de pagos electr nicos
6855 Otros Servicios NaN courier casilleros virtuales ci tecnologia a...
In [61]:
# Cambiar las categorias por las MCC para lo que tiene una relación exacta
MCC_categorias = {'Vivienda y Construcción':'Hogar',
                  'Vivienda':'Hogar',
                  'Servicios PĆŗblicos y TV Por Cable':'Hogar',
                  'Salud':'Cuidado personal' ,
                  'Entretenimiento':'Entretenimiento' ,
                  'Educación': 'Educación' ,
                  'Vehiculos':'Transporte', 
                  'Transporte':'Transporte', 
                  'Viajes':'Turismo',
                  'TecnologĆ­a y Comunicaciones':'TecnologĆ­a y comunicaciones',
                  'Otros Servicios':'',
                  'Gobierno':'Gobierno e impuestos',
                  'Fundaciones':'',
                  'Comercio':''}
MCC_categorias
Out[61]:
{'Comercio': '',
 'Educación': 'Educación',
 'Entretenimiento': 'Entretenimiento',
 'Fundaciones': '',
 'Gobierno': 'Gobierno e impuestos',
 'Otros Servicios': '',
 'Salud': 'Cuidado personal',
 'Servicios PĆŗblicos y TV Por Cable': 'Hogar',
 'TecnologĆ­a y Comunicaciones': 'TecnologĆ­a y comunicaciones',
 'Transporte': 'Transporte',
 'Vehiculos': 'Transporte',
 'Viajes': 'Turismo',
 'Vivienda': 'Hogar',
 'Vivienda y Construcción': 'Hogar'}
In [0]:
# Crea la función para relacionar ambas categorías
def categoria_MCC(cat):
    if cat in MCC_categorias.keys():
        return MCC_categorias[cat]
    else:
        return 'Otros'
In [63]:
# Procede a relacionar las categorĆ­as asociadas.
df_pse['Categoria_MCC']=df_pse['Categoria'].apply(categoria_MCC)
df_pse.drop(['Categoria'],axis=1,inplace=True)
df_pse.iloc[head_tail(5)]
Out[63]:
Empresa Span Categoria_MCC
0 BANCO DE OCCIDENTE NaN Otros
1 FIDUCIARIA CFC deshabilitada NaN Otros
2 LIBERTY SEGUROS DE VIDA NaN Otros
3 MUNICIPIO DEL CARMEN NaN Gobierno e impuestos
4 AGENCIA NACIONAL DE MINERIA NaN Gobierno e impuestos
6851 NaN municipio del patia Gobierno e impuestos
6852 NaN obtenga mas sas
6853 NaN pa ales en tu casa sas
6854 NaN portal de pagos electr nicos Otros
6855 NaN courier casilleros virtuales ci tecnologia a...
In [64]:
# Ya teniendo la bolsa de palabras a partir de la información en PSE, aplica la función definida para ajustar los textos
df_pse = organiza_texto_df(df_pse, ['Empresa'])
df_pse.drop_duplicates(inplace=True)

# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words

# Hace un split del texto en la columna Texto, y elimina aquellas de longitud menor o igual a 2 caracteres
df_pse['palabras'] = df_pse['Empresa'].apply(lambda x: [p for p in x.split() if ((len(p)>2) and (p not in (excluded_words)))])
df_pse['num_palabras'] = df_pse['palabras'].apply(lambda x: len(x))

# Organiza la bolsa de palabras para utilizar primero las que mƔs tƩrminos tienen y finalizar con las de menos
df_pse = df_pse.sort_values(by='num_palabras', ascending=False).reset_index(drop=True)

df_pse = df_pse.rename(index=str, columns={"Categoria_MCC": "categoria"})
df_pse = df_pse.loc[df_pse['num_palabras'] != 0, ['categoria', 'palabras', 'num_palabras']].reset_index(drop=True)

df_pse.iloc[head_tail(5)]
Columna Empresa procesada
Out[64]:
categoria palabras num_palabras
0 Gobierno e impuestos [martha, cecilia, caceres, gomez, curaduria, u... 11
1 Otros [asociaci, colombiana, ingenieria, quimica, pr... 10
2 [federacion, nacional, cafeteros, colombia, ca... 9
3 Entretenimiento [viaturla, viajes, turismo, eventos, rafael, l... 9
4 [federacion, nacional, cafeteros, colombia, ca... 9
3413 [hospimedics] 1
3414 Entretenimiento [protucaribe] 1
3415 [hada] 1
3416 Entretenimiento [tourexito] 1
3417 [italuminio] 1

Hasta este punto se tiene un dataframe que agrupa listados de palabras asociados a categorĆ­as.

Para poder aplicar estas clasificaciones de forma óptima sobre la totalidad de transacciones, se procederÔ a construir una matriz TF (Term Frecuency) que es frecuentemente utilizada en procesos de minería de texto. Posteriormente, se llevarÔ esta matriz a valores estrictamente booleanos, para luego hacer una revisión el cruce con los anteriores listados de términos por categoría para poder clasificar las transacciones.

In [65]:
%%time
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo, para la totalidad de palabras en el dataset de transacciones
df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[:,cols_texto].values.tolist()).str.join(' '), columns=['texto'])
df_texto['categoria'] = df_trxpse['categoria']

# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words + unique_words.tolist() + two_char_words.tolist()

# Genera la matriz TF para la totalidad de palabras en el dataset de transacciones
vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf = vectorizer.fit_transform(df_texto['texto'])

# Lleva los valores de la matriz TF a una estrictamente booleana
X_tf_bool = X_tf > 0

# A partir del TF booleano, crea un SparseDataFrame con el fin de optimizar el uso del espacio
sdf = pd.SparseDataFrame(X_tf_bool, columns=vectorizer.get_feature_names(), default_fill_value=False)

sdf.shape
CPU times: user 1min 36s, sys: 2.31 s, total: 1min 38s
Wall time: 1min 38s
In [66]:
%%time

# Genera un arreglo con el total de palabras disponibles en la matriz TF
palabras_tf = sdf.columns.tolist()

# Variable para almacenar la cantidad de transacciones que se logran clasificar a travƩs de este mƩtodo.
trx_clasificadas = 0

# Hace un loop por los tƩrminos de la bolsa de palabras, y asocia la categorƭa respectiva a los registros en el dataframe df_trxpse
for ind, fila in df_pse.iterrows():
  
    if all(palabra in palabras_tf for palabra in fila['palabras']):
      
      # Si la cantidad de palabras es mayor a 3, permite que una de ellas no coincida, de lo contrario busca total coincidencia.
      if fila['num_palabras']>=3:
        umbral_palabras = fila['num_palabras']-1
      else:
        umbral_palabras = fila['num_palabras']
      
      filtro_trx = sdf[fila['palabras']].sum(axis=1) == fila['num_palabras']
      df_trxpse.loc[(filtro_trx.tolist()) & (df_trxpse['categoria'] == ''), 'categoria'] = fila['categoria']
      
      trx_palabras = sum(filtro_trx)
      trx_clasificadas += trx_palabras
      
      if trx_palabras > 0:

        print(f'Si: {fila["palabras"]} - {trx_palabras}')
            
    else:
            
      pass #print(f'No estƔn todos los tƩrminos: {fila["palabras"]}')
            
print(f'Total de transacciones clasificadas a travƩs de este mƩtodo {trx_clasificadas}')
Si: ['corporacion', 'mesa', 'yeguas', 'country', 'club'] - 1
Si: ['conjunto', 'residencial', 'retiro', 'santa', 'monica'] - 174
Si: ['asociaci', 'padres', 'familia', 'gimnasio', 'fontana'] - 1
Si: ['corporaci', 'mesa', 'yeguas', 'country', 'club'] - 79
Si: ['universidad', 'pontificia', 'bolivariana', 'seccional', 'bucaramanga'] - 144
Si: ['servicios', 'funebres', 'san', 'pedro', 'ltda'] - 146
Si: ['alarmas', 'multi', 'servicios', 'ltda'] - 121
Si: ['sociedad', 'portuaria', 'regional', 'buenaventura'] - 969
Si: ['sociedad', 'puerto', 'industrial', 'aguadulce'] - 283
Si: ['conjunto', 'residencial', 'santa', 'ana'] - 2
Si: ['sociedad', 'portuaria', 'regional', 'buenaventura'] - 969
Si: ['camara', 'comercio', 'aburra', 'sur'] - 5669
Si: ['jairo', 'alberto', 'arango', 'gomez'] - 229
Si: ['iglesia', 'centro', 'mundial', 'avivamiento'] - 78
Si: ['camara', 'comercio', 'santa', 'marta'] - 832
Si: ['colegio', 'santa', 'ana', 'fontibon'] - 218
Si: ['colegio', 'gimnasio', 'vermont', 'medellin'] - 58
Si: ['tour', 'vacation', 'hoteles', 'azul'] - 503
Si: ['tour', 'vacation', 'hoteles', 'azul'] - 503
Si: ['caceres', 'ferro', 'finca', 'raiz'] - 443
Si: ['camara', 'comercio', 'aburra', 'sur'] - 5669
Si: ['conjunto', 'residencial', 'saint', 'moritz'] - 110
Si: ['tour', 'vacation', 'hoteles', 'azul'] - 503
Si: ['universidad', 'san', 'buenaventura', 'cali'] - 2
Si: ['global', 'mercado', 'turismo'] - 2
Si: ['empresas', 'publicas', 'armenia'] - 1
Si: ['club', 'militar', 'golf'] - 64
Si: ['proactiva', 'aguas', 'tunja'] - 294
Si: ['circulo', 'viajes', 'universal'] - 3156
Si: ['sociedad', 'san', 'pablo'] - 12
Si: ['edificio', 'bosques', 'sierralta'] - 132
Si: ['grupo', 'master', 'inmobiliario'] - 24
Si: ['seguros', 'vida', 'colpatria'] - 90
Si: ['urbanizacion', 'casas', 'ceiba'] - 3
Si: ['urbanizacion', 'calera', 'alta'] - 20
Si: ['inmobiliaria', 'diaz', 'ltda'] - 1
Si: ['urbanizacion', 'villas', 'calera'] - 13
Si: ['urbanizaci', 'balcones', 'calera'] - 68
Si: ['vasquez', 'vasquez', 'ltda'] - 2
Si: ['conjunto', 'residencial', 'cabos'] - 150
Si: ['conjunto', 'residencial', 'punta'] - 65
Si: ['edificio', 'caja', 'social'] - 77
Si: ['fondo', 'pensiones', 'proteccion'] - 1
Si: ['aguas', 'rionegro', 'esp'] - 1
Si: ['mac', 'center', 'colombia'] - 104
Si: ['camara', 'comercio', 'ibague'] - 77
Si: ['camara', 'comercio', 'ibague'] - 77
Si: ['mapfre', 'seguros', 'generales'] - 2410
Si: ['edificio', 'punta', 'oeste'] - 14
Si: ['edificio', 'jardines', 'sierralta'] - 87
Si: ['mapfre', 'servicios', 'exequiales'] - 38
Si: ['coomeva', 'medicina', 'prepagada'] - 2
Si: ['iglesia', 'cristiana', 'colombia'] - 11
Si: ['liceo', 'salazar', 'herrera'] - 1456
Si: ['maria', 'ligia', 'mendoza'] - 467
Si: ['superintendencia', 'nacional', 'salud'] - 1
Si: ['servicios', 'transito', 'manizales'] - 217
Si: ['superintendencia', 'industria', 'comercio'] - 924
Si: ['club', 'campestre', 'bucaramanga'] - 96
Si: ['fundacion', 'nuevo', 'marymount'] - 1
Si: ['colegio', 'americano', 'bucaramanga'] - 34
Si: ['universidad', 'san', 'buenaventura'] - 2
Si: ['camara', 'comercio', 'barranquilla'] - 28
Si: ['edificio', 'palos', 'verdes'] - 1
Si: ['universidad', 'catolica', 'oriente'] - 985
Si: ['universidad', 'cooperativa', 'colombia'] - 1251
Si: ['universidad', 'catolica', 'colombia'] - 558
Si: ['universidad', 'autonoma', 'caribe'] - 464
Si: ['municipio', 'san', 'gil'] - 4
Si: ['camara', 'comercio', 'sincelejo'] - 563
Si: ['camara', 'colombiana', 'infraestructura'] - 1
Si: ['hermanas', 'jesus', 'pobre'] - 31
Si: ['colegio', 'nueva', 'granada'] - 9
Si: ['corporacion', 'colegio', 'cumbres'] - 526
Si: ['colegio', 'san', 'carlos'] - 90
Si: ['colegio', 'nueva', 'granada'] - 9
Si: ['municipio', 'medellin', 'transito'] - 1489
Si: ['colegio', 'gimnasio', 'vermont'] - 600
Si: ['camara', 'comercio', 'armenia'] - 591
Si: ['corporacion', 'universitaria', 'remington'] - 190
Si: ['camara', 'comercio', 'bogota'] - 78445
Si: ['colegio', 'jorge', 'washington'] - 369
Si: ['colegio', 'agustiniano', 'salitre'] - 299
Si: ['colegio', 'agustiniano', 'tagaste'] - 89
Si: ['colegio', 'colombo', 'americano'] - 2
Si: ['camara', 'comercio', 'cali'] - 1
Si: ['camara', 'comercio', 'casanare'] - 722
Si: ['camara', 'comercio', 'manizales'] - 1320
Si: ['colegio', 'colombo', 'britanico'] - 6040
Si: ['camara', 'comercio', 'villavicencio'] - 1584
Si: ['deportivo', 'cali'] - 198
Si: ['giros', 'finanzas'] - 1496
Si: ['giros', 'finanzas'] - 1496
Si: ['fundacion', 'exito'] - 12
Si: ['global', 'securities'] - 145
Si: ['fundacion', 'todos'] - 1
Si: ['qbe', 'seguros'] - 1723
Si: ['grupo', 'colombia'] - 1
Si: ['capitalizadora', 'colpatria'] - 1936
Si: ['colombia', 'destino'] - 3
Si: ['club', 'colombia'] - 293
Si: ['cine', 'colombia'] - 143
Si: ['empresas', 'medellin'] - 433149
Si: ['departamento', 'antioquia'] - 1668
Si: ['edificio', 'reserva'] - 2
Si: ['edificio', 'calle'] - 42
Si: ['futbol', 'colombia'] - 2
Si: ['salud', 'colpatria'] - 4945
Si: ['seguros', 'bolivar'] - 15313
Si: ['qbe', 'seguros'] - 1723
Si: ['soluciones', 'colombia'] - 115
Si: ['seguros', 'colpatria'] - 6186
Si: ['seguros', 'vida'] - 18179
Si: ['banco', 'davivienda'] - 2
Si: ['sistemas', 'linea'] - 1
Si: ['tracker', 'colombia'] - 424
Si: ['virgin', 'mobile'] - 673
Si: ['informacion', 'tecnologia'] - 1
Si: ['banco', 'occidente'] - 45544
Si: ['banco', 'occidente'] - 45544
Si: ['edificio', 'torre'] - 166
Si: ['gomez', 'posada'] - 4
Si: ['gomez', 'posada'] - 4
Si: ['funeraria', 'medellin'] - 1
Si: ['grupo', 'colombia'] - 1
Si: ['comfenalco', 'antioquia'] - 563
Si: ['oriflame', 'colombia'] - 11660
Si: ['capital', 'comercial'] - 7
Si: ['ranking', 'sport'] - 179
Si: ['carvajal', 'educacion'] - 1
Si: ['productos', 'familia'] - 10
Si: ['productos', 'seguridad'] - 3
Si: ['mango', 'tango'] - 309
Si: ['edificio', 'calle'] - 42
Si: ['linea', 'guia'] - 27
Si: ['aras', 'ltda'] - 279
Si: ['casa', 'carolina'] - 12
Si: ['tracker', 'colombia'] - 424
Si: ['productos', 'familia'] - 10
Si: ['patricia', 'martinez'] - 6
Si: ['perez', 'cardona'] - 1
Si: ['easy', 'colombia'] - 1001
Si: ['grupo', 'azulado'] - 125
Si: ['falabella', 'colombia'] - 16699
Si: ['falabella', 'colombia'] - 16699
Si: ['distribuidora', 'matec'] - 147
Si: ['cometa', 'digital'] - 294
Si: ['comercio', 'virtual'] - 51
Si: ['seguridad', 'gestion'] - 10
Si: ['liberty', 'seguros'] - 7446
Si: ['cerros', 'campestre'] - 10
Si: ['edificio', 'calle'] - 42
Si: ['edificio', 'park'] - 130
Si: ['edificio', 'sierralta'] - 219
Si: ['jaramillo', 'mora'] - 227
Si: ['gestion', 'inmobiliaria'] - 6
Si: ['colegio', 'gimnasio'] - 1537
Si: ['colegio', 'victoria'] - 4
Si: ['servicios', 'educacion'] - 141
Si: ['arrendamientos', 'envigado'] - 905
Si: ['municipio', 'bucaramanga'] - 910
Si: ['alberto', 'alvarez'] - 13
Si: ['edificio', 'master'] - 2
Si: ['banco', 'davivienda'] - 2
Si: ['banco', 'bogota'] - 9080
Si: ['condominio', 'alamos'] - 3
Si: ['solaris', 'apartamentos'] - 119
Si: ['sep', 'colombia'] - 20
Si: ['universidad', 'sabana'] - 1
Si: ['universidad', 'salle'] - 1
Si: ['universidad', 'medellin'] - 374
Si: ['universidad', 'cauca'] - 820
Si: ['universidad', 'cauca'] - 820
Si: ['tienda', 'concierto'] - 17
Si: ['compra', 'ltda'] - 22
Si: ['universidad', 'antioquia'] - 12107
Si: ['universidad', 'caldas'] - 67
Si: ['universidad', 'cartagena'] - 9
Si: ['universidad', 'ibague'] - 131
Si: ['unico', 'interior'] - 1
Si: ['atletico', 'nacional'] - 3368
Si: ['sodimac', 'colombia'] - 3956
Si: ['gimnasio', 'femenino'] - 9
Si: ['dominicas', 'presentacion'] - 180
Si: ['gimnasio', 'fontana'] - 14
Si: ['star', 'linea'] - 394
Si: ['empresa', 'arauca'] - 1698
Si: ['expreso', 'brasilia'] - 4096
Si: ['expreso', 'brasilia'] - 4096
Si: ['centro'] - 8106
Si: ['academy'] - 109
Si: ['fit'] - 165
Si: ['metros'] - 235
Si: ['humano'] - 317
Si: ['tiquetera'] - 1228
Si: ['pais'] - 5320
Si: ['credibanco'] - 633
Si: ['hora'] - 1077
Si: ['avantel'] - 1576
Si: ['fit'] - 165
Si: ['fit'] - 165
Si: ['movilid'] - 1381
Si: ['devinorte'] - 6647
Si: ['foto'] - 211
Si: ['metroparques'] - 202
Si: ['jardin'] - 1589
Si: ['claro'] - 46704
Si: ['true'] - 150
Si: ['edatel'] - 6427
Si: ['tranexco'] - 257
Si: ['flytech'] - 34207
Si: ['avantel'] - 1576
Si: ['autofusa'] - 283
Si: ['aerorepublica'] - 1752
Si: ['cargo'] - 564
Si: ['credimapfre'] - 5644
Si: ['lubricantes'] - 419
Si: ['efigas'] - 3391
Si: ['global'] - 1330
Si: ['solyner'] - 237
Si: ['protein'] - 108
Si: ['avianca'] - 96997
Si: ['credimapfre'] - 5644
Si: ['chevyplan'] - 14842
Si: ['tierra'] - 193
Si: ['finesa'] - 23534
Si: ['rentabien'] - 417
Si: ['inmobiliaria'] - 10793
Si: ['siempre'] - 432
Si: ['azul'] - 1415
Si: ['sociedad'] - 1308
Si: ['inmobiliaria'] - 10793
Si: ['alcala'] - 229
Si: ['colombia'] - 65060
Si: ['icetex'] - 5593
Si: ['condominio'] - 1026
Si: ['servientrega'] - 132
Si: ['cargo'] - 564
Si: ['avianca'] - 96997
Si: ['soluciones'] - 48877
Si: ['construbienes'] - 604
Si: ['seguros'] - 90163
Si: ['acuarela'] - 139
Si: ['inmobiliarios'] - 180
Si: ['servicredito'] - 6707
Si: ['certicamara'] - 798
Si: ['distribuidora'] - 168
Si: ['colombia'] - 65060
Si: ['vehigrupo'] - 479
Si: ['unicef'] - 227
Si: ['tours'] - 239
Si: ['col'] - 4458
Si: ['gourmet'] - 184
Si: ['avantel'] - 1576
Si: ['angel'] - 987
Si: ['hoteles'] - 2842
Si: ['mattelsa'] - 2663
Si: ['store'] - 1131
Total de transacciones clasificadas a travƩs de este mƩtodo 1502479
CPU times: user 22min 39s, sys: 8.67 s, total: 22min 47s
Wall time: 22min 48s
In [67]:
# Presenta el grƔfico con los resultados obtenidos hasta el momento
fig, ax = plt.subplots()

sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Se encuentra que este método permitió identificar mÔs de 1.5M de transacciones, lo cual es un resultado interesante teniendo presente que se partió de la categorización que ya tiene el mismo PSE en su pÔgina Web. La única limitante de este método es que se depende de que esta categorización esté bien hecha, y que la información en la pÔgina se actualiza permanentemente para incluir las nuevas empresas que firmen convenios con PSE.

Se debe tener presente también que a pesar de este avance en la categorización, aun quedan casi 6M de transacciones por clasificar, por lo cual se continuarÔ el proceso mediante un nuevo acercamiento diferente detallado en la siguiente sección.

Asignación de categoría a partir de bolsa de palabras manual

Para este proceso se partirÔ de la misma matriz TF, convertida a booleana, que se utilizó en la metodología de clasificación anterior.

La diferencia es que acÔ se realizarÔ la categorización a partir de una bolsa de palabras creada manualmente por los miembros del equipo, donde se relacionaron palabras "comunes" que consideramos podían estar asociadas a cada una de las categorías.

In [68]:
# Validación del contenido de la matriz sdf (booleana obtenida a partir de la TF)
sdf.iloc[head_tail(5)]
Out[68]:
aaa aac aaf abba abdomen abierta abierto abonados abono abonos ... zapatos zenfone zion zipaquira zona zonas zrii zuame zuleta zuluaga
0 False False False False False False False False False False ... False False False False False False False False False False
1 False False False False False False False False False False ... False False False False False False False False False False
2 False False False False False False False False False False ... False False False False False False False False False False
3 False False False False False False False False False False ... False False False False False False False False False False
4 False False False False False False False False False False ... False False False False False False False False False False
11853777 False False False False False False False False False False ... False False False False False False False False False False
11853778 False False False False False False False False False False ... False False False False False False False False False False
11853779 False False False False False False False False False False ... False False False False False False False False False False
11853780 False False False False False False False False False False ... False False False False False False False False False False
11853781 False False False False False False False False False False ... False False False False False False False False False False

10 rows Ɨ 4050 columns

In [69]:
# Pasa el DataFrame de la Bolsa de Palabras por la función para organizar el texto respectivo
df_bolsa_palabras = organiza_texto_df(df_bolsa_palabras)
df_bolsa_palabras.head()
Columna Comida procesada
Columna Hogar procesada
Columna Cuidado personal procesada
Columna Entretenimiento procesada
Columna Educación procesada
Columna Transporte procesada
Columna Viajes procesada
Columna Ahorro procesada
Columna Pago de deudas procesada
Columna Ingresos procesada
Columna Retiros en efectivo procesada
Columna Mascotas procesada
Columna Moda procesada
Columna TecnologĆ­a y comunicaciones procesada
Columna Otros procesada
Columna Gobierno e impuestos procesada
Columna Seguros procesada
Columna Almacenes de cadena procesada
Columna Otros servicios financieros procesada
Out[69]:
Comida Hogar Cuidado personal Entretenimiento Educación Transporte Viajes Ahorro Pago de deudas Ingresos Retiros en efectivo Mascotas Moda Tecnología y comunicaciones Otros Gobierno e impuestos Seguros Almacenes de cadena Otros servicios financieros
0 novaventa gas yanbal boleteria jardin flytech tiquetes ahorro voluntario credito mascota leonisa emcali municipio seguro exito
1 restaurante acueducto eps boletas colegio flypass avianca aporte voluntario amex mascotas zara emcaliach predial seguros falabella
2 restaurantes eab colsanitas boleta universidad licencia conduccion flight pensiones voluntarias visa veterinario mattelsa etb impuesto poliza alkosto
3 alimento empresas publicas de medellin salud sura deporte pregado transporte booking ahorro mastercard tennis claro impuestos polizas jumbo
4 alimentos alcantarillado medicina procinal matricula accenorte ada old mutual master card nafnaf tigo industria y comercio vida exitocom
In [70]:
# Cambia la forma del dataframe con la bolsa de palabras para facilitar el procesamiento
df_bolsa_palabras = df_bolsa_palabras.unstack().reset_index()
df_bolsa_palabras = df_bolsa_palabras.rename(index=str, columns={"level_0": "categoria", 0: "palabras"})
df_bolsa_palabras = df_bolsa_palabras.loc[df_bolsa_palabras['palabras'] != '', ['categoria', 'palabras']]

# Hace un split del texto en la columna palabras, y elimina aquellas de longitud menor o igual a 2 caracteres
df_bolsa_palabras['palabras'] = df_bolsa_palabras['palabras'].apply(lambda x: [p for p in x.split() if len(p)>2])
df_bolsa_palabras['num_palabras'] = df_bolsa_palabras['palabras'].apply(lambda x: len(x))

# Organiza la bolsa de palabras para utilizar primero las que mƔs tƩrminos tienen y finalizar con las de menos
# lo anterior con el fin de minimizar errores aprovechando las coincidencias mƔs amplias (mƔs tƩrminos)
df_bolsa_palabras = df_bolsa_palabras.sort_values(by='num_palabras', ascending=False).reset_index(drop=True)

# Presenta el dataframe con la bolsa de palabras resultante
df_bolsa_palabras.iloc[head_tail(5)]
Out[70]:
categoria palabras num_palabras
0 Seguros [grupo, empresarial, bolivar, sociedades, boli... 7
1 Seguros [seguros, alfa, s.a, seguros, vida, alfa, s.a.] 7
2 Seguros [pan, american, life, colombia, compania, segu... 7
3 Seguros [bmi, colombia, compania, seguros, vida, s.a.] 6
4 Seguros [solunion, colombia, seguros, credito, s.a.] 5
291 Educación [academia] 1
292 Educación [school] 1
293 Educación [escolar] 1
294 Educación [formacion] 1
295 Almacenes de cadena [panamericanacomco] 1
In [71]:
%%time

# Genera un arreglo con el total de palabras disponibles en la matriz TF
palabras_tf = sdf.columns.tolist()

# Variable para almacenar la cantidad de transacciones que se logran clasificar a travƩs de este mƩtodo.
trx_clasificadas = 0

# Hace un loop por los tƩrminos de la bolsa de palabras, y asocia la categorƭa respectiva a los registros en el dataframe df_trxpse
for ind, fila in df_bolsa_palabras.iterrows():
  
    if all(palabra in palabras_tf for palabra in fila['palabras']):
      
      filtro_trx = sdf[fila['palabras']].sum(axis=1) == fila['num_palabras']
      df_trxpse.loc[(filtro_trx.tolist()) & (df_trxpse['categoria'] == ''), 'categoria'] = fila['categoria']

      trx_palabras = sum(filtro_trx)
      trx_clasificadas += trx_palabras
      
      if trx_palabras>0:

        print(f'Si: {fila["palabras"]} - {trx_palabras}')
            
    else:
            
      pass #print(f'No estƔn todos los tƩrminos: {fila["palabras"]}')
            
print(f'Total de transacciones clasificadas a travƩs de este mƩtodo {trx_clasificadas}')
Si: ['der', 'mpal', 'placa'] - 1188
Si: ['empresas', 'publicas', 'medellin'] - 433147
Si: ['flash', 'mobile'] - 2152
Si: ['impuesto', 'predial'] - 72935
Si: ['recarga', 'tag'] - 24215
Si: ['club', 'campestre'] - 544
Si: ['cruz', 'roja'] - 17
Si: ['camara', 'comercio'] - 90879
Si: ['centro', 'salud'] - 1
Si: ['licencia', 'conduccion'] - 26758
Si: ['impuesto', 'vehicular'] - 4810
Si: ['impuesto', 'vehicular'] - 4810
Si: ['industria', 'comercio'] - 15572
Si: ['canchas', 'futbol'] - 487
Si: ['cancha', 'futbol'] - 178
Si: ['servicios', 'educativos'] - 6988
Si: ['aportes', 'obligaciones'] - 10833
Si: ['tarjeta', 'profesional'] - 1355
Si: ['servicios', 'metro'] - 13749
Si: ['cine', 'colombia'] - 143
Si: ['eticket', 'avianca'] - 96510
Si: ['seguros', 'bolivar'] - 15313
Si: ['cartera', 'vencida'] - 38
Si: ['ahorro', 'voluntario'] - 10576
Si: ['empresas', 'publicas'] - 433148
Si: ['credito', 'hipotecario'] - 29169
Si: ['master', 'card'] - 48450
Si: ['derechos', 'grado'] - 2422
Si: ['mac', 'center'] - 151
Si: ['conjunto', 'residencial'] - 5311
Si: ['old', 'mutual'] - 2127
Si: ['aporte', 'voluntario'] - 2778
Si: ['servicios', 'publicos'] - 9769
Si: ['salud', 'sura'] - 10
Si: ['pensiones', 'voluntarias'] - 3945
Si: ['rebaja', 'virtual'] - 22931
Si: ['plan', 'complementario'] - 12739
Si: ['conj', 'res'] - 10500
Si: ['ktronix'] - 2077
Si: ['telefonia'] - 623794
Si: ['celular'] - 11783
Si: ['antiplan'] - 20849
Si: ['minutos'] - 2058
Si: ['leonisa'] - 4476
Si: ['mapfre'] - 15574
Si: ['mattelsa'] - 2663
Si: ['cmr'] - 143815
Si: ['libranza'] - 180
Si: ['prestamo'] - 31829
Si: ['mascota'] - 645
Si: ['mascotas'] - 435
Si: ['telefonica'] - 982
Si: ['virgin'] - 55187
Si: ['emcaliach'] - 87916
Si: ['etb'] - 79461
Si: ['claro'] - 46704
Si: ['avantel'] - 1576
Si: ['miavantel'] - 42789
Si: ['novaventa'] - 22612
Si: ['movistar'] - 870
Si: ['seguro'] - 2846
Si: ['seguros'] - 90163
Si: ['poliza'] - 13981
Si: ['polizas'] - 46294
Si: ['vida'] - 25053
Si: ['suramericana'] - 97375
Si: ['soat'] - 33990
Si: ['mapfre'] - 15574
Si: ['exito'] - 169487
Si: ['falabella'] - 171228
Si: ['alkosto'] - 34610
Si: ['jumbo'] - 131
Si: ['exitocom'] - 13710
Si: ['exito'] - 169487
Si: ['falabellacom'] - 555
Si: ['carullacom'] - 1559
Si: ['gobierno'] - 520130
Si: ['icetex'] - 5593
Si: ['postpago'] - 307528
Si: ['une'] - 985
Si: ['parlante'] - 262
Si: ['audifonos'] - 552
Si: ['mouse'] - 117
Si: ['teclado'] - 250
Si: ['cargador'] - 142
Si: ['samsung'] - 1007
Si: ['municipio'] - 35485
Si: ['predial'] - 106698
Si: ['impuesto'] - 159300
Si: ['impuestos'] - 6528
Si: ['valorizacion'] - 16137
Si: ['apostilla'] - 32847
Si: ['snrpse'] - 19311
Si: ['alcaldia'] - 10323
Si: ['lineru'] - 15355
Si: ['urbanizacion'] - 2054
Si: ['metroalarmas'] - 2508
Si: ['arrendamiento'] - 31733
Si: ['arrendamientos'] - 6811
Si: ['inmobiliario'] - 1603
Si: ['inmobiliarios'] - 180
Si: ['yanbal'] - 13884
Si: ['eps'] - 24076
Si: ['colsanitas'] - 31391
Si: ['medicina'] - 21569
Si: ['prepagada'] - 35034
Si: ['herbalife'] - 16123
Si: ['cena'] - 1515
Si: ['desayuno'] - 1921
Si: ['almuerzo'] - 987
Si: ['medisanitas'] - 8760
Si: ['coomeva'] - 1602
Si: ['salud'] - 23940
Si: ['eps'] - 24076
Si: ['gimnasio'] - 3626
Si: ['boleteria'] - 44584
Si: ['boletas'] - 950
Si: ['telesentinel'] - 3512
Si: ['condominio'] - 1026
Si: ['deporte'] - 159
Si: ['edificio'] - 4604
Si: ['alimentos'] - 2933
Si: ['pizza'] - 2641
Si: ['hamburguesa'] - 2590
Si: ['helado'] - 166
Si: ['pollo'] - 875
Si: ['sushi'] - 869
Si: ['ensalada'] - 167
Si: ['bebidas'] - 4902
Si: ['gaseosa'] - 3776
Si: ['rodizio'] - 646
Si: ['plato'] - 1396
Si: ['platos'] - 2609
Si: ['malteada'] - 214
Si: ['alitas'] - 714
Si: ['burritos'] - 107
Si: ['donuts'] - 439
Si: ['gas'] - 67398
Si: ['acueducto'] - 23520
Si: ['eab'] - 16611
Si: ['alcantarillado'] - 8741
Si: ['hogar'] - 125699
Si: ['multiplay'] - 125500
Si: ['codensa'] - 13163
Si: ['inmobiliaria'] - 10793
Si: ['boleta'] - 9544
Si: ['chevyplan'] - 14842
Si: ['eafit'] - 669
Si: ['upb'] - 140
Si: ['flytech'] - 34207
Si: ['transporte'] - 13388
Si: ['accenorte'] - 4246
Si: ['paseya'] - 10893
Si: ['autos'] - 13831
Si: ['tiquetes'] - 47077
Si: ['avianca'] - 96997
Si: ['flight'] - 104498
Si: ['booking'] - 88221
Si: ['ada'] - 4412
Si: ['aerorepublica'] - 1752
Si: ['travel'] - 16308
Si: ['hotel'] - 1595
Si: ['tiquete'] - 126034
Si: ['viajes'] - 3882
Si: ['viaje'] - 1113
Si: ['estadia'] - 296
Si: ['tour'] - 1408
Si: ['turismo'] - 401
Si: ['restaurante'] - 2001
Si: ['ahorro'] - 13398
Si: ['credito'] - 421060
Si: ['amex'] - 62374
Si: ['visa'] - 127481
Si: ['mastercard'] - 20580
Si: ['escuela'] - 1552
Si: ['sena'] - 221
Si: ['cinemark'] - 1593
Si: ['corporacion'] - 2197
Si: ['teatro'] - 635
Si: ['festival'] - 657
Si: ['running'] - 179
Si: ['cine'] - 1724
Si: ['evento'] - 4590
Si: ['feria'] - 1112
Si: ['futbol'] - 1763
Si: ['netflix'] - 574
Si: ['deezer'] - 2628
Si: ['desfile'] - 185
Si: ['compensar'] - 13177
Si: ['jardin'] - 1589
Si: ['colegio'] - 50456
Si: ['universidad'] - 23380
Si: ['matricula'] - 28119
Si: ['curso'] - 11829
Si: ['posgrado'] - 3607
Si: ['estudiante'] - 2690
Si: ['estudiantes'] - 530
Si: ['academia'] - 321
Si: ['school'] - 2391
Si: ['escolar'] - 2515
Si: ['formacion'] - 842
Si: ['panamericanacomco'] - 1271
Total de transacciones clasificadas a travƩs de este mƩtodo 6869188
CPU times: user 4min 22s, sys: 1.75 s, total: 4min 24s
Wall time: 4min 23s
In [72]:
# Presenta el grƔfico con los resultados obtenidos hasta el momento
fig, ax = plt.subplots()

sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue');
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Se encuentra que con esta herramienta, cuya complejidad es baja pues solo requiere que un grupo de personas acuerden tƩrminos comunes asociados a cada categorƭa, se lograrƭan cubrir casi 7 millones de transacciones, aunque acƔ se suman algunas que ya tenƭan categorƭa asignada a partir de uno de los mƩtodos anteriores, por lo cual, luego de aplicar esta metodologƭa, aun quedan aproximadamente 3M de transacciones pendientes.

En la siguiente sección se harÔ una exploración adicional que busca terminar de clasificar las transacciones pendientes.

Asignación de categorías mediante clusters

En esta etapa se harÔ una exploración de los datos que aun no tienen categoría mediante un ejercicio de clusterización, bajo el cual se buscarÔ identificar mediante el método del codo cuÔl es la cantidad ideal de clusters para identificar los datos, y posteriormente se analizarÔ la información que quede en cada cluster para validar si se identifican nuevas transacciones a agrupar en las diferentes categorías.

Este procesamiento se harĆ” sobre los registros a los que no se les ha asignado aun una categorĆ­a.

In [73]:
%%time
# Se crea el nuevo DF que agrupa todos los campos de texto en uno solo, pero no sobre dataframe original (df_trxpse) sino sobre el resumido (df_texto_trxpse)
df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[df_trxpse['categoria'] == '',cols_texto].fillna('').values.tolist()).str.join(' '), columns=['texto'])

# Unifica las 3 fuentes de palabras a excluir en un solo arreglo
excluded_words = stopwords_spanish + no_relevant_words + low_frec_words.tolist() + unique_words.tolist() + two_char_words.tolist()

# Genera la matriz TF-IDF
vectorizer = TfidfVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
X_tf_idf = vectorizer.fit_transform(df_texto['texto'])

print(f"La matriz TF-IDF queda con un total de {X_tf_idf.shape[0]} textos diferentes, y {X_tf_idf.shape[1]} tƩrminos asociados luego de los filtros")
La matriz TF-IDF queda con un total de 2962313 textos diferentes, y 160 tƩrminos asociados luego de los filtros
CPU times: user 24.8 s, sys: 432 ms, total: 25.3 s
Wall time: 25.3 s
In [74]:
%%time

# Define las cantidades de los clusters a generar (mƭnimo, mƔximo, paso entre cantidades)
min_k = 5
max_k = 50
step_k = 10

# Crea un dataframe donde se almacenarÔn los resultados de las evaluaciones de cada combinación de clusters
res_clusters = pd.DataFrame({'k': np.arange(min_k, max_k, step_k)}, columns=['k'])
res_clusters['wcss'] = 0

# Comienza a crearlos cluster indicados, guardando los resultados asociados.
for ind in res_clusters.index:
  
  val_k = res_clusters.at[ind,'k']
  clusterer = KMeans(n_clusters = val_k, random_state = 42)
  cluster_labels = clusterer.fit_predict(X_tf_idf)
  
  wcss = clusterer.inertia_
  
  res_clusters.at[ind,'wcss'] = wcss
  
  #Agrega la información del cluster asignado, para el postprocesamiento
  df_texto['k'+str(val_k).zfill(2)] = cluster_labels
  
  print(f"Finalizó el proceso para k={val_k}")
Finalizó el proceso para k=5
Finalizó el proceso para k=15
Finalizó el proceso para k=25
Finalizó el proceso para k=35
Finalizó el proceso para k=45
CPU times: user 31min 32s, sys: 10.3 s, total: 31min 43s
Wall time: 31min 42s
In [75]:
res_clusters
Out[75]:
k wcss
0 5 1581836
1 15 982734
2 25 675798
3 35 502130
4 45 364525
In [76]:
# Genera la grÔfica del codo, para identificar la cantidad óptima de clusters
res_clusters.plot(x='k', y='wcss', marker='o');
plt.xticks(res_clusters['k']);

Se encuentra hasta este punto que hay un codo con k=15, por lo cual se procede a tomar esta cantidad de clusters y a hacer un anÔlisis de las palabras asociadas a cada uno de ellos, para a partir de esto definir cuÔl es la categoría respectiva e ir avanzando en la asignación de una clasificación por grupos de palabras.

In [77]:
# Define el K ideal y una cadena de caracteres asociado a esta cantidad, que se pueda usar para seleccionar la información asociada.
k_ideal = 15
k_ideal_str = 'k'+str(k_ideal).zfill(2)

df_texto[k_ideal_str].value_counts()
Out[77]:
1     1336002
4      350887
6      229011
9      190551
7      135937
3      112667
5      109356
8       93890
10      86260
14      68966
12      63711
11      57614
13      47364
2       43043
0       37054
Name: k15, dtype: int64
In [78]:
df_texto.iloc[head_tail(10)]
Out[78]:
texto k05 k15 k25 k35 k45
0 pago de la planilla cesantias cedula de ciudad... 1 4 20 2 1
1 pago de saldo plan familiar ce 4 1 18 34 30
2 pago de saldo plan familiar ce 4 1 18 34 30
3 cpv servicios financieros bancos 1 4 1 0 7
4 pago factura asociado bancoomevapfa servicios... 1 14 10 16 13
5 edif mirabell ph bog servicios financieros b... 1 1 20 2 1
6 edif mirabell ph bog servicios financieros b... 1 1 20 2 1
7 liquidacion iuva no cc 4 1 3 1 3
8 irma fortun de mejia 4 1 3 1 3
9 irma de mejia 4 1 3 1 3
2962303 recarga nequi pse servicios financieros bancos 1 6 2 9 9
2962304 recarga nequi pse servicios financieros bancos 1 6 2 9 9
2962305 recarga nequi pse servicios financieros bancos 1 6 2 9 9
2962306 multas y/o reproceso de material bibliografico... 4 1 3 1 3
2962307 carga pse daviplata cc 4 1 3 1 3
2962308 cartera idc 3 5 6 11 4
2962309 inscripcion programas pregrado cc valeriabarr... 4 1 3 1 3
2962310 pago de recibo preara cc 4 1 3 1 3
2962311 compra de pin para tarjeta de lavanderia cc 4 9 14 32 28
2962312 compra de pin para tarjeta de lavanderia cc 4 9 14 32 28

A partir de la información asociada a cada cluster, procede a mostrar las palabras mÔs frecuentes en el texto de las transacciones de cada cluster.

In [79]:
for k_i in range(k_ideal):
  
    print('Cluster {}'.format(k_i))

    # Genera la matriz TF para la totalidad de palabras en el dataset de transacciones
    vectorizer = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
    X_tf = vectorizer.fit_transform(df_texto.loc[df_texto[k_ideal_str] == k_i, 'texto'])

    # Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
    words = np.array(vectorizer.get_feature_names())
    counts = X_tf.sum(axis=0).A1

    # Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
    all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)

    # Crea ua grƔfica con los principales 25 tƩrminos de cada cluster
    all_words.head(25).plot.barh(title='Top 25 Palabras mƔs Frecuentes - '+str(k_ideal)+' clusters - cluster '+str(k_i));
    plt.gca().invert_yaxis();
Cluster 0
Cluster 1
Cluster 2
Cluster 3
Cluster 4
Cluster 5
Cluster 6
Cluster 7
Cluster 8
Cluster 9
Cluster 10
Cluster 11
Cluster 12
Cluster 13
Cluster 14

Se encuentra que hay un cluster grande con mÔs de 1M de registros y que tiene una gran dispersión de términos diferentes, que no son claramente relacionables frente a una categoría.

Por otro lado, se identifica que varios de los clusters tienen como factor común las palabras "servicios" y "financieros", lo cual estÔ atado a pagos de clientes de Bancolombia que se hacen a otros bancos pero para los cuales no es fÔcil identificar cuÔl es su destino final. Debido a esto se procede a crear una nueva categoría denominada "Otros servicios bancarios" donde se agruparÔn todas estas transacciones.

Asƭ mismo, se identifica que en general los demƔs clusters estƔn asociados a tƩrminos en los que es dificil identificar cuƔl es su categorƭa, por lo cual estos se llevan a la categorƭa "Otros"

In [0]:
# Primero se hace la clasificación de los registros por sector y subsector
df_trxpse.loc[(df_trxpse['categoria'] == '') &
              (df_trxpse['sector'] == 'servicios financieros'), 'categoria'] = 'Otros servicios financieros'

# Finalmente, hace una categorización de los registros que aun estén pendientes por clasificar, bajo la categoría "Otros"
df_trxpse.loc[(df_trxpse['categoria'] == ''), 'categoria'] = 'Otros'
In [81]:
# Presenta el grƔfico con los resultados obtenidos hasta el momento, organizƔndolos por frecuencia
fig, ax = plt.subplots()

sns.countplot(y='categoria',data=df_trxpse, color= 'darkblue', 
              order=df_trxpse['categoria'].value_counts().index);
plt.title("Clasificaciones hasta el momento");
ax.xaxis.set_major_formatter(EngFormatter());
ax.set(xlabel='Transacciones', ylabel='');
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Se encuentra hasta acÔ que luego de aplicar las diferentes metodologías se logra clasificar mÔs de la mitad de los registros, teniendo presente que durante el proceso se buscó ser estrictos para evitar terminar clasificando transacciones en la categoría incorrecta. Para corroborar esto, en la siguiente sección se harÔ una descripción de los principales términos asociados a cada categoría.

Descripción de las categorías identificadas

A continuación se hace una descripción de las diferentes categorías identificadas, logrando encontrar las palabras mÔs frecuentes para cada una mediante un countplot y un WordCloud que presentan las palabras mÔs comunes para cada categoría.

In [82]:
# Genera un arreglo de "excluded_words", sin tener en cuenta las de baja frecuencia para lograr un anƔlisis mƔs profundo
excluded_words = stopwords_spanish + no_relevant_words + two_char_words.tolist()

# Define el tamaƱo de las grƔficas
#fig, ax = plt.subplots(figsize=(8,3))

# Hace una iteración entre las diferentes categorías para generar el top 20 de las palabras mÔs frecuentes
resultados = pd.DataFrame(df_trxpse['categoria'].value_counts())
for ind, fila in resultados.iterrows():
  
  vectorizer = {}
  
  if fila['categoria']>0:
    
    # Se crea el nuevo DF que agrupa todos los campos de texto en uno solo
    df_texto = pd.DataFrame(pd.Series(df_trxpse.loc[df_trxpse['categoria'] == ind, cols_texto].values.tolist()).str.join(' '), columns=['texto'])
        
    # Genera la matriz TF para la totalidad de palabras en el dataset de transacciones, excluyendo solo stopwords
    vectorizer[ind] = CountVectorizer(encoding='latin-1', lowercase=True, stop_words=excluded_words)
    X_tf = vectorizer[ind].fit_transform(df_texto['texto'])
    
    # Obtiene el arreglo con las palabras y la frecuencia asociada a cada una
    words = np.array(vectorizer[ind].get_feature_names())
    counts = X_tf.sum(axis=0).A1

    # Crea un dataframe con la totalidad de palabras, y la frecuencia de cada una
    all_words = pd.DataFrame({'frecuencia': counts}, index=words).sort_values(by='frecuencia', ascending=False)
    
    # Crea la grƔfica asociada
    all_words.head(20).plot.barh(title='Top 25 Palabras mƔs Frecuentes categoria '+ind);
    plt.gca().invert_yaxis();
    plt.show()
    
    # Genera un wordcloud con las palabras mƔs frecuentes
    wordcloud = WordCloud(width=800,
                          height=400,
                          min_font_size=8,
                          max_font_size=60,
                          relative_scaling=0.4,
                          background_color="white").generate_from_frequencies(all_words['frecuencia'].head(200).to_dict())
    plt.imshow(wordcloud, interpolation='bilinear')
    plt.axis("off")
    plt.show()
  
In [83]:
#df_trxpse[df_trxpse['ref1'].str.contains('panamericanacomco ')].groupby(cols_texto+['categoria']).size().reset_index().sort_values(by=0, ascending=False)
df_texto[df_texto['texto'].str.contains('pregrado')].groupby(['texto']).size().reset_index().sort_values(by=0, ascending=False)
#df_trxpse.loc[df_trxpse['categoria'] == 'Viajes' , cols_texto]
Out[83]:
texto 0

Descarga archivo

In [0]:
df_trxpse.to_csv('./trx_pse_clasificado.csv',index=False)
In [90]:
!ls
adc.json		       dt_subsector_categoria.csv
df_bolsa_palabras.csv	       dt_trxpse_personas_2016_2018_muestra_adjt.csv
df_trx_pse_clasificado.csv     sample_data
dt_clasif_objetivo.csv	       stopwords_spanish.csv
dt_info_pagadores_muestra.csv  trx_pse_clasificado.csv
In [91]:
# 2. Create & upload a file text file.
uploaded = drive.CreateFile({'title': 'trx_pse_clasificado.csv'})
uploaded.SetContentFile('trx_pse_clasificado.csv')
uploaded.Upload()
print('Uploaded file with ID {}'.format(uploaded.get('id')))

#1OXlhMm9ulvEj1ONS2QDLHCJ0v5siOqo_
#1LTq2ijSn2RSiNBHeoBZkWfakDPdEo4W8
Uploaded file with ID 1AHcd_C-KD0FwFE2afjNfZ_fjOurI1NeN

Modelo Clasificador Categorias

Dentro de las aplicaciones de este ejercicio, es interesante analizar si es posible generar un modelo de clasificación que pueda ser usado en conjunto con un PFM para facilitar llevar las cuentas con una herramienta , es por esto que nos tomamos la tarea de desarrollarlo luego de clasificar las transacciones. Dentro de los modelos que generalmente han demostrado mejor desempeño en la clasificación de texto han sobresalido la Naive Bayes y la maquina de soporte vectorial lineal, por lo tanto nos propusimos a crearlos

In [171]:
!pip install imbalanced-learn
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.model_selection import GridSearchCV

from imblearn.pipeline import Pipeline as make_pipeline_imb
from imblearn.metrics import classification_report_imbalanced
from imblearn.over_sampling import SMOTE

from sklearn.feature_extraction.text import TfidfVectorizer

from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from xgboost import XGBClassifier

from sklearn.metrics import classification_report,accuracy_score
Collecting imbalanced-learn
  Downloading https://files.pythonhosted.org/packages/c5/ea/f027ceb21114abe8189a2804640b2d5dd49a7a271c4814695482c5bc94d8/imbalanced_learn-0.4.2-py3-none-any.whl (166kB)
    100% |ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 174kB 6.8MB/s 
Requirement already satisfied: scipy>=0.13.3 in /usr/local/lib/python3.6/dist-packages (from imbalanced-learn) (0.19.1)
Requirement already satisfied: numpy>=1.8.2 in /usr/local/lib/python3.6/dist-packages (from imbalanced-learn) (1.14.6)
Collecting scikit-learn>=0.20 (from imbalanced-learn)
  Downloading https://files.pythonhosted.org/packages/0c/b2/05be9b6da9ae4a4c54f537be22e95833f722742a02b1e355fdc09363877c/scikit_learn-0.20.0-cp36-cp36m-manylinux1_x86_64.whl (5.3MB)
    100% |ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆ| 5.3MB 7.8MB/s 
Installing collected packages: scikit-learn, imbalanced-learn
  Found existing installation: scikit-learn 0.19.2
    Uninstalling scikit-learn-0.19.2:
      Successfully uninstalled scikit-learn-0.19.2
Successfully installed imbalanced-learn-0.4.2 scikit-learn-0.20.0
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-171-f4e72b2f3c98> in <module>()
      1 get_ipython().system('pip install imbalanced-learn')
      2 from sklearn.model_selection import train_test_split
----> 3 from sklearn.pipeline import Pipeline
      4 from sklearn.model_selection import GridSearchCV
      5 

/usr/local/lib/python3.6/dist-packages/sklearn/pipeline.py in <module>()
     16 
     17 from .base import clone, TransformerMixin
---> 18 from .utils import Parallel, delayed
     19 from .externals import six
     20 from .utils.metaestimators import if_delegate_has_method

ImportError: cannot import name 'Parallel'

---------------------------------------------------------------------------
NOTE: If your import is failing due to a missing package, you can
manually install dependencies using either !pip or !apt.

To view examples of installing some common dependencies, click the
"Open Examples" button below.
---------------------------------------------------------------------------
In [0]:
# Crea la función para procesar el texto de los dataframes
def organiza_texto_df(df_proceso, columnas='', type_category=False):
  
  # Si no se pasó un vector de columnas, crea un vector con todas las columnas del dataframe
  if len(columnas) == 0:
    
    columnas = df_proceso.columns.values
    
  for col in columnas:

    # Si se pasó la bandera de que se debe trabajar con dtype = category, hace el procesamiento teniendo en cuenta esto (category -> object -> category)
    if type_category:
        
      df_proceso[col] = df_proceso[col].astype('object').fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
                                                  str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ').astype('category')
        
    # De lo contrario, trabaja directamente con la columna teniendo en cuenta que es string
    else:

      df_proceso[col] = df_proceso[col].fillna('').str.normalize('NFKD').str.encode('ascii', errors='ignore').str.decode('utf-8').\
                                                  str.lower().str.replace(r'\\n','').str.replace(r'[_|#@=:"]',' ')
      
    print(f'Columna {col} procesada')
    
  #Devuelve el dataframe prosesado
  return(df_proceso)
In [0]:
# Buscando eficiencia, cargamos unicamente las columnas siguientes
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion','categoria']
dtypes = {'ref1':'str', 'ref2':'str', 'ref3':'str', 'sector':'str', 'subsector':'str', 'descripcion':'str','categoria':'str'}
df_trx_pse_clasificado = pd.read_csv('trx_pse_clasificado.csv', usecols=[3,4,5,6,7,8,15], dtype=dtypes, names=cols_texto,header=None)
In [10]:
%%time
# Genera un arreglo con los nombres de los campos de texto que describen la transacción
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']

df_trx_pse_clasificado = organiza_texto_df(df_trx_pse_clasificado, cols_texto, True)
Columna ref1 procesada
Columna ref2 procesada
Columna ref3 procesada
Columna sector procesada
Columna subsector procesada
Columna descripcion procesada
CPU times: user 3min 22s, sys: 1.83 s, total: 3min 23s
Wall time: 3min 24s
In [0]:
cols_texto = ['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion']
df_trx_pse_clasificado['Texto']=pd.Series(df_trx_pse_clasificado[cols_texto].values.tolist()).str.join(' ')
df_trx_pse_clasificado.drop(['ref1', 'ref2', 'ref3', 'sector', 'subsector', 'descripcion'],axis=1,inplace=True)
In [12]:
# Valida la calidad de los datos
%%time
import re
def clean(texto):
    clean_texto=re.sub("[^a-zA-Z-0-9]", " ", str(texto)) 
    return clean_texto
  
df_trx_pse_clasificado['Texto']=df_trx_pse_clasificado['Texto'].apply(clean)
CPU times: user 34.4 s, sys: 406 ms, total: 34.8 s
Wall time: 34.9 s
In [0]:
# Valida la calidad de los datos
df_trx_pse_clasificado=df_trx_pse_clasificado[df_trx_pse_clasificado['categoria'].isna()==False]
df_trx_pse_clasificado=df_trx_pse_clasificado[df_trx_pse_clasificado['categoria']!='categoria']
In [0]:
# Carga las palabras que se identificaron previamente como no deseadas
df_excluded_words = pd.read_csv('excluded_words.csv',sep=';')
df_excluded_words=df_excluded_words[df_excluded_words['0'].isna()==False]
excluded_words=df_excluded_words['0'].values.tolist()
In [0]:
X = df_trx_pse_clasificado['Texto']
y = df_trx_pse_clasificado['categoria']
In [16]:
y.value_counts()
Out[16]:
Otros                          5478301
TecnologĆ­a y comunicaciones    1854726
Hogar                           810393
Gobierno e impuestos            762648
Otros servicios financieros     754281
Pago de deudas                  631765
Almacenes de cadena             391669
Viajes                          298909
Seguros                         261835
Cuidado personal                163117
Transporte                      145726
Educación                       142996
Entretenimiento                  83389
Comida                           43520
Ahorro                           18642
Moda                             10785
Mascotas                          1080
Name: categoria, dtype: int64

Ya que se cuenta con un set de datos relativamente grande, y dado el caso de que no contamos con recursos para procesar todos los datos, se extrae una muestra del dataset para entrenar un modelo de clasificación.

In [0]:
# Genera un sample de todo el dataset proporcionalmente a la cantidad de cata categotia
from sklearn.model_selection import StratifiedShuffleSplit
sss = StratifiedShuffleSplit(n_splits=250000000,test_size=0.5,random_state=42)
In [0]:
sample_train = []
sample_test = []
for train_sample,test_sample in  sss.split(X,y):
    sample_train.append(train_sample)
    sample_test.append(test_sample)
    break
In [0]:
# Se filtra con los nuevos indices
X=df_trx_pse_clasificado.iloc[sample_train[0]]['Texto']
y=df_trx_pse_clasificado.iloc[sample_train[0]]['categoria']
In [20]:
# Se verifica la distribución de categorias para la muestra extraida
y.value_counts()
Out[20]:
Otros                          2739151
TecnologĆ­a y comunicaciones     927363
Hogar                           405196
Gobierno e impuestos            381324
Otros servicios financieros     377140
Pago de deudas                  315883
Almacenes de cadena             195835
Viajes                          149454
Seguros                         130918
Cuidado personal                 81559
Transporte                       72863
Educación                        71498
Entretenimiento                  41694
Comida                           21760
Ahorro                            9321
Moda                              5392
Mascotas                           540
Name: categoria, dtype: int64
In [21]:
y.shape
Out[21]:
(5926891,)
In [0]:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.5, random_state=42)

Multinomial Naives Bayes

In [0]:
Bayes_text_clf =  make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
                               ('SMOTE',SMOTE(random_state=42)),
                               ('MultinomialNB',  MultinomialNB())])

Bayes_param = {'MultinomialNB__alpha': [1]} 

clf_GC_Bayes = GridSearchCV(Bayes_text_clf,Bayes_param,cv=2,n_jobs=5)
In [0]:
%%time
clf_GC_Bayes.fit(X_train,y_train)
In [36]:
%%time
y_pred = clf_GC_Bayes.predict(X_test)
CPU times: user 1.82 s, sys: 4.98 ms, total: 1.82 s
Wall time: 1.82 s
In [37]:
print(classification_report(y_test,y_pred))
                             precision    recall  f1-score   support

                     Ahorro       0.01      0.97      0.01       582
        Almacenes de cadena       0.93      1.00      0.96     11994
                     Comida       0.95      0.75      0.84      1337
           Cuidado personal       0.97      0.95      0.96      5290
                  Educación       0.89      0.90      0.89      4478
            Entretenimiento       0.75      0.80      0.77      2455
       Gobierno e impuestos       1.00      0.99      0.99     24407
                      Hogar       1.00      0.89      0.94     25543
                   Mascotas       0.08      0.78      0.14        23
                       Moda       0.36      0.49      0.42       340
                      Otros       0.48      0.01      0.03    103327
             Pago de deudas       0.99      0.99      0.99     19137
                    Seguros       0.94      0.99      0.96      8154
TecnologĆ­a y comunicaciones       1.00      0.98      0.99     46648
                 Transporte       0.98      0.96      0.97      3253
                     Viajes       1.00      0.95      0.97      8922

                  micro avg       0.59      0.59      0.59    265890
                  macro avg       0.77      0.84      0.74    265890
               weighted avg       0.78      0.59      0.60    265890

Ya que solo entrenamos el dataset con una pequeña porción del set, comprobamos directamente contra todo el dataframe original

In [0]:
y_pred = clf_GC_Bayes.predict(df_trx_pse_clasificado['Texto'])
In [39]:
print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))
                             precision    recall  f1-score   support

                     Ahorro       0.01      0.97      0.01     18656
        Almacenes de cadena       0.94      1.00      0.97    390398
                     Comida       0.94      0.73      0.82     43512
           Cuidado personal       0.97      0.95      0.96    163117
                  Educación       0.88      0.90      0.89    140626
            Entretenimiento       0.76      0.79      0.78     83129
       Gobierno e impuestos       1.00      0.99      0.99    762644
                      Hogar       1.00      0.89      0.94    800103
                   Mascotas       0.08      0.60      0.14      1080
                       Moda       0.43      0.58      0.49     10785
                      Otros       0.48      0.01      0.03   3271961
             Pago de deudas       0.99      0.99      0.99    615335
                    Seguros       0.94      0.99      0.97    261874
TecnologĆ­a y comunicaciones       1.00      0.98      0.99   1547198
                 Transporte       0.98      0.95      0.97     99156
                     Viajes       1.00      0.96      0.98    298910

                  micro avg       0.60      0.60      0.60   8508484
                  macro avg       0.77      0.83      0.74   8508484
               weighted avg       0.79      0.60      0.60   8508484

Linear Support Vector Classification

In [0]:
SVM_text_clf =  make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
                               ('SMOTE',SMOTE(random_state=42)),
                               ('SVM_lineal',  SVC(kernel='linear'))])

SVM_param = {'SVM_lineal__C': [1]} 

clf_GC_SMV = GridSearchCV(SVM_text_clf,SVM_param,n_jobs=6)
In [0]:
%%time
clf_GC_SMV.fit(X_train,y_train)
In [0]:
%%time
y_pred = clf_GC_SMV.predict(X_test)
In [0]:
print(classification_report(y_test,y_pred))

Por cuestiones de tiempo, en este Jupyter, no se ejecutó el código, sino en otro equipo Local. Se adjunta imagen en donde se muestra el resultado del entrenamiento de la mÔquina de soporte vectorial en el entrenamiento con 265000 filas probandolo con el split que se utilizo cuando se tenian 8500000 de registros clasificados.

In [0]:
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
%matplotlib inline
In [50]:
plt.figure(figsize=(13,9))
img=mpimg.imread('265000.PNG')
imgplot = plt.imshow(img)
plt.axis('off');
In [0]:
%%time
y_pred = clf_GC_SMV.predict(df_trx_pse_clasificado['Texto'])
In [0]:
print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))

Se adjunta imagen en donde se muestra el resultado del entrenamiento de la mƔquina de soporte vectorial en el entrenamiento con 265000 filas probandolo directamente con todo el dataset.

In [51]:
plt.figure(figsize=(13,9))
img=mpimg.imread('85000000.PNG')
imgplot = plt.imshow(img)
plt.axis('off');

En este punto podemos concluir que el procesamiento de los datos que establecimos permite generar un modelo de clasificación con muy buen desempeño !

Es de resaltar el buen desempeño y preprocesamiento de los datos ya que el entrenamiento se realizó con 265890 registros, y se comprobó directamente contra todos los 8.500.000 de datos que se tenían

Otros Modelos

In [0]:
scores = []
classifiers=['RandomForestClassifier','Logistic Regression','XGBOOST']

models=[RandomForestClassifier(n_estimators=100),
        LogisticRegression(),
        XGBClassifier()]

for model in models:
    text_clf =  make_pipeline_imb([('tfidf', TfidfVectorizer(encoding='latin-1',stop_words=excluded_words)),
                                   ('SMOTE',SMOTE(random_state=42)),
                                   ('model', model)])
    print(model)
    print(' ')
    text_clf.fit(X_train,y_train)
    y_pred=text_clf.predict(X_test)
    scores.append(accuracy_score(y_pred,y_test))
    print(classification_report(y_test,y_pred))
    print(' ')
    #y_pred=text_clf.predict(df_trx_pse_clasificado['Texto'])
    #print(classification_report(df_trx_pse_clasificado['categoria'],y_pred))
    #print(' ')
    
models_dataframe=pd.DataFrame(scores,index=classifiers)   
models_dataframe.columns=['Accuracy']
models_dataframe.sort_values('Accuracy',ascending=False)

Análisis de los pagadores (clientes)

En esta sección se buscarÔ identificar cuÔles son las principales características de los clientes, que determinan su probabilidad de realizar una transacción, para lo cual se procederÔ a:

  1. Analizar y preparar los datos de los pagadores (clientes)
  2. Cruzarlos con el resumen de transacciones por cada tipo para cada cliente
  3. Hacer anƔlisis de correlaciones entre cada categorƭa y los clientes que la realizan.

Entendimiento de los datos de los pagadores (clientes)

En esta sección se cargarÔ la información de los clientes, se harÔn unos ajustes iniciales como por ejemplo cambiar las categorías por la descripción asociada, y lugeo hacer una expliración estadística y visual de los datos.

In [97]:
# Carga la información de los pagadores
df_pagadores = pd.read_csv('./dt_info_pagadores_muestra.csv',           
                            header=None,
                            sep=",",
                            names=['id_cliente', 'seg_str', 'ocupacion', 'tipo_vivienda', 'nivel_academico',
                                   'estado_civil', 'genero', 'edad', 'ingreso_rango'],
                            dtype={'id_cliente': 'object', 'seg_str': 'category', 
                                   'ocupacion': 'object', 'tipo_vivienda': 'object', 
                                   'nivel_academico': 'object', 'estado_civil': 'object', 
                                   'genero': 'object', 'edad': 'object', 'ingreso_rango': 'category'})

# Explora el dataframe resultante
df_pagadores.iloc[head_tail(5)]
Out[97]:
id_cliente seg_str ocupacion tipo_vivienda nivel_academico estado_civil genero edad ingreso_rango
0 18 PERSONAL PLUS 5 O U M M 92 e. (4.4 5.5MM]
1 32 PERSONAL PLUS E F T M M 80 i. (8.7 Inf)
2 41 EMPRENDEDOR 3 O I W M 90 b. (1.1 2.2MM]
3 47 EMPRENDEDOR 7 NaN I I M 86 c. (2.2 3.3MM]
4 71 PERSONAL 5 O S M M 79 e. (4.4 5.5MM]
338601 338486 PERSONAL 2 NaN NaN S F 19 b. (1.1 2.2MM]
338602 338512 PERSONAL 2 NaN NaN S F 19 c. (2.2 3.3MM]
338603 338567 PERSONAL 1 NaN NaN S M 18 No disponible
338604 338578 PERSONAL 1 NaN NaN I M 18 b. (1.1 2.2MM]
338605 338594 PERSONAL 1 NaN NaN S M 18 a. (0 1.1MM]
In [0]:
# Carga la asignación de variables que se tiene para cada campo, con el fin de hacer el mapeo
map_ocupaciones = {'E': 'SOCIO O EMPLEADO - SOCIO',
                   'I': 'DESEMPLEADO CON INGRESOS',
                   'O': 'OTRA',
                   'P': 'INDEPENDIENTE',
                   'S': 'DESEMPLEADO SIN INGRESOS',
                   '1': 'EMPLEADO',
                   '2': 'ESTUDIANTE',
                   '3': 'INDEPENDIENTE',
                   '4': 'HOGAR',
                   '5': 'JUBILADO',
                   '6': 'AGRICULTOR',
                   '7': 'GANADERO',
                   '8': 'COMERCIANTE',
                   '9': 'RENTISTA DE CAPITAL'}

map_tipo_vivienda = {'A': 'ALQUILADA',
                      'R': 'ALQUILADA',
                      'F': 'FAMILIAR',
                      'I': 'NO INFORMA',
                      'P': 'PROPIA',
                      'O': 'PROPIA'}

map_nivel_academico = {'H': 'BACHILLERATO',
                        'B': 'BACHILLERATO',
                        'U': 'UNIVERSITARIO', 
                        'E': 'ESPECIALIZACION', 
                        'N': 'NINGUNO',
                        'P': 'PRIMARIA',  
                        'S': 'POSTGRADO',  
                        'T': 'TECNICO',  
                        'I': 'NO INFORMA'} 

map_estado_civil = {'S': 'SOLTERO',  
                    'M': 'CASADO',  
                    'F': 'DESCONOCIDO', 
                    'I': 'NO INFORMA', 
                    'D': 'DIVORCIADO', 
                    'W': 'VIUDO',  
                    'O': 'OTRO'}  

map_genero = {'M': 'Masculino', 'F': 'Femenino'}

#Hace el mapping de cada campo categórico, y lo convierte a tipo category
df_pagadores['ocupacion'] = df_pagadores['ocupacion'].map(map_ocupaciones).astype('category')
df_pagadores['tipo_vivienda'] = df_pagadores['tipo_vivienda'].map(map_tipo_vivienda).astype('category')
df_pagadores['nivel_academico'] = df_pagadores['nivel_academico'].map(map_nivel_academico).astype('category')
df_pagadores['estado_civil'] = df_pagadores['estado_civil'].map(map_estado_civil).astype('category')
df_pagadores['genero'] = df_pagadores['genero'].map(map_genero).astype('category')
In [99]:
# Explora el dataframe resultante
df_pagadores.iloc[head_tail(5)]
Out[99]:
id_cliente seg_str ocupacion tipo_vivienda nivel_academico estado_civil genero edad ingreso_rango
0 18 PERSONAL PLUS JUBILADO PROPIA UNIVERSITARIO CASADO Masculino 92 e. (4.4 5.5MM]
1 32 PERSONAL PLUS SOCIO O EMPLEADO - SOCIO FAMILIAR TECNICO CASADO Masculino 80 i. (8.7 Inf)
2 41 EMPRENDEDOR INDEPENDIENTE PROPIA NO INFORMA VIUDO Masculino 90 b. (1.1 2.2MM]
3 47 EMPRENDEDOR GANADERO NaN NO INFORMA NO INFORMA Masculino 86 c. (2.2 3.3MM]
4 71 PERSONAL JUBILADO PROPIA POSTGRADO CASADO Masculino 79 e. (4.4 5.5MM]
338601 338486 PERSONAL ESTUDIANTE NaN NaN SOLTERO Femenino 19 b. (1.1 2.2MM]
338602 338512 PERSONAL ESTUDIANTE NaN NaN SOLTERO Femenino 19 c. (2.2 3.3MM]
338603 338567 PERSONAL EMPLEADO NaN NaN SOLTERO Masculino 18 No disponible
338604 338578 PERSONAL EMPLEADO NaN NaN NO INFORMA Masculino 18 b. (1.1 2.2MM]
338605 338594 PERSONAL EMPLEADO NaN NaN SOLTERO Masculino 18 a. (0 1.1MM]
In [100]:
# Hace un describe de las diferentes columnas del dataframe
df_desc_numeric = df_pagadores.describe().T.reset_index(level=0).rename(columns={"index": "columna"})
df_desc_numeric
Out[100]:
columna count unique top freq
0 id_cliente 338606 338606 206116 1
1 seg_str 338606 5 PERSONAL 233568
2 ocupacion 331769 13 EMPLEADO 210488
3 tipo_vivienda 166386 4 FAMILIAR 83736
4 nivel_academico 294313 8 UNIVERSITARIO 135754
5 estado_civil 332006 7 SOLTERO 175878
6 genero 332897 2 Femenino 169336
7 edad 338606 109 29 14001
8 ingreso_rango 338606 11 b. (1.1 2.2MM] 105099

Se encuentra que:

  • En la columna cliente solo hay valores Ćŗnicos
  • Las columnas segmento, edad e ingreso_rango no tienen nulos.
  • Las demĆ”s columnas tienen algunos nulos
  • La columna edad tiene 109 valores diferentes, lo cual es sospechoso para información de este tipo.

En la siguiente sección se hace un anÔlisis exploratorio de la información de los clientes

In [101]:
# Genera un dataframe a partir del cual se harÔ una la exploración visual de las variables dependiendo de si son numéricas o categóricas
df_dtype = pd.DataFrame(df_pagadores.dtypes).reset_index(level=0)
df_dtype.rename(columns={"index": "columna", 0: "tipo"}, inplace=True)  # Renombra las columnas para facilitar su procesamiento

# Crea el espacio para las grƔficas
fig, ax = plt.subplots()

# Procede a crear las grƔficas
filas_numericas = ['id_cliente'] # Variables a excluir de la revisión grÔfica
for index, fila in df_dtype.iterrows():
  
  if fila["columna"] not in filas_numericas:
    
    sns.countplot(y=fila["columna"],data=df_pagadores, color= 'darkblue', 
                  order=df_pagadores[fila["columna"]].value_counts().index);
    plt.title("Frecuencia atributo - "+fila["columna"]);
    ax.xaxis.set_major_formatter(EngFormatter());
    ax.set(xlabel='Transacciones', ylabel='');
    plt.show();
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Preparación de los datos

A partir de lo que se identifica en cada grƔfica, se procederƔ a hacer los siguientes ajustes iniciales:

  • Para estado_civil, unificar DESCONOCIDO con NO INFORMA, pues ambos tienen el mismo sentido, y en general los otros campos utilizan la categorĆ­a NO INFORMA.
  • Ajustar el formato de la categorĆ­a de ingresos, para que sea mĆ”s fĆ”cil de entender.
  • Eliminar los valores de la variable edad que no sean numĆ©ricos.

MÔs adelante, se procederÔ a hacer una revisión de los valores nulos de cada variable con su respectiva eliminación/imputación.

Finalmente, se harĆ” el tratamiento de los valores atĆ­picos de la columna edad, para luego redefinir esta categorĆ­a y llevarla de nĆŗmeros a rangos.

In [102]:
# Ajusta los valores de estado_civil
df_pagadores.loc[df_pagadores['estado_civil'] == 'DESCONOCIDO', 'estado_civil'] = 'NO INFORMA'
df_pagadores['estado_civil'] = df_pagadores['estado_civil'].astype('object').astype('category')
df_pagadores['estado_civil'].value_counts()
Out[102]:
SOLTERO       175878
CASADO         79954
NO INFORMA     58666
DIVORCIADO     12057
VIUDO           3357
OTRO            2094
Name: estado_civil, dtype: int64
In [0]:
# Ajusta el formato de la columna del rango de ingresos
df_pagadores['ingreso_rango'] = df_pagadores['ingreso_rango'].str.slice(3)
In [104]:
#Revisa los campos no numƩricos de la variable edad
import re
def valida_no_num(texto):
    no_num=re.sub("[0-9]", "", str(texto)) 
    return no_num
  
valores_no_num=df_pagadores['edad'].apply(valida_no_num)
valores_no_num.value_counts()
Out[104]:
      332315
\N      6290
-          1
Name: edad, dtype: int64

Se encuentra que hay 2 tipos de texto diferentes en el campo edad, por lo cual se procede a eliminar esos valores y a reemplazarlos por Nan.

In [0]:
# Reemplaza los valores extraƱos, convierte la columna en numƩrica y reemplaza los valores atƭpicos por la mediana
df_pagadores['edad'] = df_pagadores['edad'].replace('\\N', None)
df_pagadores['edad'] = df_pagadores['edad'].replace('-', None)
df_pagadores['edad'] = df_pagadores['edad'].astype('int16')
In [106]:
df_pagadores.iloc[head_tail(5)]
Out[106]:
id_cliente seg_str ocupacion tipo_vivienda nivel_academico estado_civil genero edad ingreso_rango
0 18 PERSONAL PLUS JUBILADO PROPIA UNIVERSITARIO CASADO Masculino 92 (4.4 5.5MM]
1 32 PERSONAL PLUS SOCIO O EMPLEADO - SOCIO FAMILIAR TECNICO CASADO Masculino 80 (8.7 Inf)
2 41 EMPRENDEDOR INDEPENDIENTE PROPIA NO INFORMA VIUDO Masculino 90 (1.1 2.2MM]
3 47 EMPRENDEDOR GANADERO NaN NO INFORMA NO INFORMA Masculino 86 (2.2 3.3MM]
4 71 PERSONAL JUBILADO PROPIA POSTGRADO CASADO Masculino 79 (4.4 5.5MM]
338601 338486 PERSONAL ESTUDIANTE NaN NaN SOLTERO Femenino 19 (1.1 2.2MM]
338602 338512 PERSONAL ESTUDIANTE NaN NaN SOLTERO Femenino 19 (2.2 3.3MM]
338603 338567 PERSONAL EMPLEADO NaN NaN SOLTERO Masculino 18 disponible
338604 338578 PERSONAL EMPLEADO NaN NaN NO INFORMA Masculino 18 (1.1 2.2MM]
338605 338594 PERSONAL EMPLEADO NaN NaN SOLTERO Masculino 18 (0 1.1MM]

Antes de proceder con el anÔlisis e imputación de los valores de edad, se procede con un anÔlisis general de valores nulos, que busca identificar si hay columnas o filas con una gran cantidad de nulos, y a partir de ello se defina si se eliminan o solo se imputan.

In [107]:
#Carga la cantidad de filas y de columnas del dataframe
df_pagadores_filas, df_pagadores_columnas = df_pagadores.shape[0], df_pagadores.shape[1]

# Valida la cantidad de nulos en cada una de las columnas, y % frente a la cantidad total de registros
umbral_nan = 20

df_nan = pd.DataFrame(df_pagadores.isnull().sum()).reset_index(level=0)
df_nan.rename(columns={"index": "columna", 0: "num_nulos"}, inplace=True)
df_nan['pct_nulos'] = (df_nan['num_nulos']/df_pagadores_filas)*100

df_nan.sort_values(by=['pct_nulos'], ascending=False).iloc[0:11,:].plot.barh(x='columna', 
                                                                             y='pct_nulos', 
                                                                             color='darkgray')

# Genera grƔfica con el porcentaje de nulos por cada campo
plt.axvline(umbral_nan, color='red', linestyle='--');
plt.title('Top 25 Palabras mƔs Frecuentes');
In [108]:
# Ahora valida la cantidad de nulos en cada una de las filas, y % frente a la cantidad total de registros
umbral_nan_r = 50

df_nan_r = pd.DataFrame(df_pagadores.isnull().sum(axis=1)).reset_index(level=0)
df_nan_r.rename(columns={"index": "indice", 0: "num_nulos"}, inplace=True)
df_nan_r['pct_nulos'] = (df_nan_r['num_nulos']/df_pagadores_columnas)*100

df_nan_r.sort_values(by=['pct_nulos'], ascending=False).iloc[0:31,:].plot.barh(x='indice', 
                                                                               y='pct_nulos', 
                                                                               color='darkgray',
                                                                               figsize=(10, 10))
plt.axvline(umbral_nan_r, color='red', linestyle='--');
plt.title('Top 30 registros con mayor cantidad de nulos');
In [109]:
df_nan_r['pct_nulos'].value_counts()
Out[109]:
0.000000     166266
11.111111    128125
22.222222     35896
55.555556      5061
33.333333      2595
44.444444       663
Name: pct_nulos, dtype: int64

Se encuentra que hay varios campos nulos, para estos se procede de la siguiente forma:

  • Eliminar la columna tipo_vivienda pues tiene mĆ”s del 50% de los datos nulos, y de los que no son nulos hay mĆ”s de 10.000 con la categorĆ­a "NO INFORMA".
  • Eliminar los 5.061 registros que tienen mĆ”s del 50% de sus campos nulos, pues estos generan ruido en el procesamiento de la información.
  • Reemplazar los datos nulos de las columnas nivel_academico y estado_civil por "NO INFORMA", pues esas columnas ya tienen esa categorĆ­a y un volumen importante de registros en ella.
  • Reemplazar los datos nulos de las columnas ocupacion y genero por la moda, pues estas columnas no tienen la categorĆ­a "NO INFORMA".
In [0]:
#Eliminar la columna tipo_vivienda
df_pagadores = df_pagadores.drop('tipo_vivienda', axis=1)

#Elimina las filas que tienen una cantidad de nulos superior al umbral
df_pagadores.dropna(axis="index", thresh=(df_pagadores_columnas*(100-umbral_nan_r)/100), inplace=True)

# Columnas a reemplazar por la moda
moda = df_pagadores['ocupacion'].mode().iloc[0]
df_pagadores['ocupacion'].fillna(moda, inplace=True)

moda = df_pagadores['genero'].mode().iloc[0]
df_pagadores['genero'].fillna(moda, inplace=True)

# Columnas a reemplazar por "NO INFORMA"
df_pagadores['nivel_academico'].fillna('NO INFORMA', inplace=True)
df_pagadores['estado_civil'].fillna('NO INFORMA', inplace=True)

Finalmente, se procede a ajustar los valores de la columna edad, que es la única numérica dentro de la información de los pagadores (clientes). Para esto se harÔ primero un "describe" del campo y luego una exploración mediante boxplots.

In [111]:
# hace un ddescribe de la columna edad
df_pagadores['edad'].describe()
Out[111]:
count    333545.000000
mean         37.193503
std          13.724808
min        -975.000000
25%          28.000000
50%          34.000000
75%          43.000000
max         118.000000
Name: edad, dtype: float64
In [112]:
# Grafica el boxplot de los valores del campo edad
fig, ax = plt.subplots(figsize=(8,3))

df_pagadores['edad'].plot.box(vert=False);
plt.title("Edades de los clientes");
ax.set(xlabel='AƱos', ylabel='');
In [113]:
df_pagadores['edad'].sort_values().iloc[head_tail(5)]
Out[113]:
27526    -975
163702      0
338579      0
179032      0
152325      0
34512     118
245208    118
245288    118
241947    118
214976    118
Name: edad, dtype: int16

Se encuentra que hay valores atƭpicos asociados a edades negativas o iguales a 0, por lo cual se procede a hacer un reemplazo de todas las edades menores a 5 aƱos por la mediana.

Así mismo, se encuentra que hay múltiples registros con edad de 118 años, por lo cual se procede a cambiar también las edades mayores a 95 años también por la mediana.

In [114]:
# Hace el ajuste para los atĆ­picos
df_pagadores.loc[df_pagadores['edad'] < 5, 'edad'] = df_pagadores['edad'].median()
df_pagadores.loc[df_pagadores['edad'] > 95, 'edad'] = df_pagadores['edad'].median()

# Cambia los valores nulos por la mediana
df_pagadores['edad'] = df_pagadores['edad'].fillna(df_pagadores['edad'].median())

# Grafica la cantidad de palabras de los campos descriptores de la transacción contactenados
fig, ax = plt.subplots(figsize=(8,3))

df_pagadores['edad'].plot.box(vert=False);
plt.title("Edades de los clientes");
ax.set(xlabel='AƱos', ylabel='');

Se encuentra que las edades quedan ya en rangos adecuados para continuar con el anƔlisis. Finalmente, se procede cambiar esta variable a categorƭas, para facilitar el entendimiento de los datos que se busca mediante este ejercicio.

Para esto, se procede a crear una función que genera un texto para cada edad, indicando en qué rango estÔ.

In [115]:
# FUnción para discrtizar las edades
def discretiza_edades(edad):
  min_edad = 20
  max_edad = 90
  salto = 10
  
  if edad<min_edad:
    return('<'+str(int(min_edad)))
  elif edad>=max_edad:
    return('>='+str(int(max_edad)))
  else:
    div = (edad//salto)*salto
    return(str(int(div))+'-'+str(int(div+salto-1)))
     
df_pagadores['edad'] = df_pagadores['edad'].apply(discretiza_edades)
df_pagadores.iloc[head_tail(5)]
Out[115]:
id_cliente seg_str ocupacion nivel_academico estado_civil genero edad ingreso_rango
0 18 PERSONAL PLUS JUBILADO UNIVERSITARIO CASADO Masculino >=90 (4.4 5.5MM]
1 32 PERSONAL PLUS SOCIO O EMPLEADO - SOCIO TECNICO CASADO Masculino 80-89 (8.7 Inf)
2 41 EMPRENDEDOR INDEPENDIENTE NO INFORMA VIUDO Masculino >=90 (1.1 2.2MM]
3 47 EMPRENDEDOR GANADERO NO INFORMA NO INFORMA Masculino 80-89 (2.2 3.3MM]
4 71 PERSONAL JUBILADO POSTGRADO CASADO Masculino 70-79 (4.4 5.5MM]
338601 338486 PERSONAL ESTUDIANTE NO INFORMA SOLTERO Femenino <20 (1.1 2.2MM]
338602 338512 PERSONAL ESTUDIANTE NO INFORMA SOLTERO Femenino <20 (2.2 3.3MM]
338603 338567 PERSONAL EMPLEADO NO INFORMA SOLTERO Masculino <20 disponible
338604 338578 PERSONAL EMPLEADO NO INFORMA NO INFORMA Masculino <20 (1.1 2.2MM]
338605 338594 PERSONAL EMPLEADO NO INFORMA SOLTERO Masculino <20 (0 1.1MM]

Para cerrar este ejercicio, se hace una última exploración de la frecuencia de cada categoría en cada una de las variables.

In [116]:
# Genera un dataframe a partir del cual se harÔ una la exploración visual de las variables dependiendo de si son numéricas o categóricas
df_dtype = pd.DataFrame(df_pagadores.dtypes).reset_index(level=0)
df_dtype.rename(columns={"index": "columna", 0: "tipo"}, inplace=True)  # Renombra las columnas para facilitar su procesamiento

fig, ax = plt.subplots()

filas_numericas = ['id_cliente']

i=0    # Variable para almacenar la posición de la grÔfica asociada
for index, fila in df_dtype.iterrows():
  
  if fila["columna"] not in filas_numericas:
    
    sns.countplot(y=fila["columna"],data=df_pagadores, color= 'darkblue', 
                  order=df_pagadores[fila["columna"]].value_counts().index);
    plt.title("Frecuencia atributo - "+fila["columna"]);
    ax.xaxis.set_major_formatter(EngFormatter());
    ax.set(xlabel='Transacciones', ylabel='');
    plt.show();
/usr/local/lib/python3.6/dist-packages/seaborn/categorical.py:1428: FutureWarning: remove_na is deprecated and is a private function. Do not use.
  stat_data = remove_na(group_data)

Como en este ejercicio se busca identificar cuÔl es el perfil específico de los clientes que hacen cada tipo de transacción, el último proceso que se harÔ con los datos es generar dummy variables para cada categoría, de tal forma que posteriormente se puedan validar cuÔles de las columnas dummy tienen mayor correlación con la variable buscada.

In [117]:
df_pagadores_dummies = pd.get_dummies(df_pagadores[['seg_str', 'ocupacion', 'nivel_academico', 'estado_civil', 
                                                    'genero', 'edad', 'ingreso_rango']], 
                                      prefix=['segm', 'ocup', 'estu', 'eciv', 'gen', 'edad', 'ingr'])

df_pagadores_dummies['id_cliente'] = df_pagadores['id_cliente']
df_pagadores_dummies.set_index('id_cliente', inplace=True)

df_pagadores_dummies.iloc[head_tail(5)]
Out[117]:
segm_EMPRENDEDOR segm_OTRO segm_PERSONAL segm_PERSONAL PLUS segm_PREFERENCIAL ocup_AGRICULTOR ocup_COMERCIANTE ocup_DESEMPLEADO CON INGRESOS ocup_DESEMPLEADO SIN INGRESOS ocup_EMPLEADO ... ingr_(0 1.1MM] ingr_(1.1 2.2MM] ingr_(2.2 3.3MM] ingr_(3.3 4.4MM] ingr_(4.4 5.5MM] ingr_(5.5 6.6MM] ingr_(6.6 7.6MM] ingr_(7.6 8.7MM] ingr_(8.7 Inf) ingr_disponible
id_cliente
18 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 1 0 0 0 0 0
32 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 1 0
41 1 0 0 0 0 0 0 0 0 0 ... 0 1 0 0 0 0 0 0 0 0
47 1 0 0 0 0 0 0 0 0 0 ... 0 0 1 0 0 0 0 0 0 0
71 0 0 1 0 0 0 0 0 0 0 ... 0 0 0 0 1 0 0 0 0 0
338486 0 0 1 0 0 0 0 0 0 0 ... 0 1 0 0 0 0 0 0 0 0
338512 0 0 1 0 0 0 0 0 0 0 ... 0 0 1 0 0 0 0 0 0 0
338567 0 0 1 0 0 0 0 0 0 1 ... 0 0 0 0 0 0 0 0 0 1
338578 0 0 1 0 0 0 0 0 0 1 ... 0 1 0 0 0 0 0 0 0 0
338594 0 0 1 0 0 0 0 0 0 1 ... 1 0 0 0 0 0 0 0 0 0

10 rows Ɨ 54 columns

Finalmente, y como lo que se busca es revisar la relación entre los clientes y el tipo de transacciones que realiza, se crea un dataframe que agrupa para cada cliente la cantidad de transacciones que hace. Para esto se utiliza la función pivot_table.

In [118]:
# Crea la pivot_table con la información requerida
df_trxpse_x_cliente = df_trxpse.pivot_table(values=['valor_trx'], 
                                            index=['id_cliente'],
                                            columns=['categoria'],
                                            margins=False,
                                            aggfunc=np.sum)

# Organiza los datos (eliminar levels)
df_trxpse_x_cliente.columns = df_trxpse_x_cliente.columns.droplevel()
df_trxpse_x_cliente = df_trxpse_x_cliente.rename_axis(None).rename_axis(None, axis=1)

# Cambia los NaN por 0
df_trxpse_x_cliente.fillna(0, inplace=True)

# Explora el DF resultante
df_trxpse_x_cliente.head()
Out[118]:
Comida Hogar Cuidado personal Entretenimiento Educación Transporte Viajes Ahorro Pago de deudas Mascotas Moda Tecnología y comunicaciones Otros Gobierno e impuestos Seguros Almacenes de cadena Otros servicios financieros
1 0.0 0.00 0.0 0.0 0.0 0.0 0.0 9942522.53 32588454.31 0.0 0.0 155835.53 82570.87 0.00 0.00 0.00 0.0
10 0.0 3523278.59 0.0 0.0 0.0 0.0 0.0 0.00 0.00 0.0 0.0 388847.97 3587594.32 5788222.53 0.00 0.00 0.0
100 0.0 1456792.68 0.0 0.0 0.0 0.0 0.0 0.00 0.00 0.0 0.0 236034.53 13698955.05 278061.03 0.00 6175901.91 0.0
1000 0.0 34112656.94 0.0 0.0 0.0 0.0 0.0 0.00 0.00 0.0 0.0 904148.05 5458410.93 0.00 376213.71 0.00 0.0
10000 0.0 0.00 0.0 0.0 0.0 0.0 0.0 0.00 0.00 0.0 0.0 229188.67 0.00 0.00 0.00 0.00 0.0
In [119]:
# Crea una nueva matriz booleana que indica si tuvo o no transacciones
df_trxpse_x_cliente_bool = df_trxpse_x_cliente>1000

# Convierte la matriz en 1 y 0
df_trxpse_x_cliente_bool = df_trxpse_x_cliente_bool.astype(int)

df_trxpse_x_cliente_bool.head()
Out[119]:
Comida Hogar Cuidado personal Entretenimiento Educación Transporte Viajes Ahorro Pago de deudas Mascotas Moda Tecnología y comunicaciones Otros Gobierno e impuestos Seguros Almacenes de cadena Otros servicios financieros
1 0 0 0 0 0 0 0 1 1 0 0 1 1 0 0 0 0
10 0 1 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0
100 0 1 0 0 0 0 0 0 0 0 0 1 1 1 0 1 0
1000 0 1 0 0 0 0 0 0 0 0 0 1 1 0 1 0 0
10000 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0

Ya teniendo preparadas tanto la matriz de los pagadores (clientes) como la matriz de las transacciones por cliente, por valor y booleana, se procede a hacer el merge.

In [120]:
# Hace el merge entre los 2 sets de datos, mediante el id_cliente (una columna en una, y el Ć­ndice en la otra)
df_clientes_trxpse = pd.merge(df_pagadores_dummies,
                              df_trxpse_x_cliente_bool,
                              how='left',
                              left_index=True, 
                              right_index=True)

# Explora los resultados
df_clientes_trxpse.iloc[head_tail(5)]
Out[120]:
segm_EMPRENDEDOR segm_OTRO segm_PERSONAL segm_PERSONAL PLUS segm_PREFERENCIAL ocup_AGRICULTOR ocup_COMERCIANTE ocup_DESEMPLEADO CON INGRESOS ocup_DESEMPLEADO SIN INGRESOS ocup_EMPLEADO ... Ahorro Pago de deudas Mascotas Moda TecnologĆ­a y comunicaciones Otros Gobierno e impuestos Seguros Almacenes de cadena Otros servicios financieros
id_cliente
18 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 1 1 0 0 0 0
32 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 0 0 1 0 0 0
41 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 1 0 0 0 0
47 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 1 1 0 0 0 0
71 0 0 1 0 0 0 0 0 0 0 ... 0 0 0 0 1 1 1 0 0 0
338486 0 0 1 0 0 0 0 0 0 0 ... 0 0 0 0 0 1 0 0 0 0
338512 0 0 1 0 0 0 0 0 0 0 ... 0 1 0 0 0 1 0 0 0 0
338567 0 0 1 0 0 0 0 0 0 1 ... 0 0 0 0 0 1 0 0 0 0
338578 0 0 1 0 0 0 0 0 0 1 ... 0 0 0 0 1 1 1 0 0 0
338594 0 0 1 0 0 0 0 0 0 1 ... 0 0 0 0 0 1 0 0 0 1

10 rows Ɨ 71 columns

In [121]:
df_clientes_trxpse.shape
Out[121]:
(333545, 71)

Modelamiento - Características de cliente por categoría de trx

Para el modelamiento de las principales características de cliente que se asocian a cada tipo de transacción, se crearÔ una matriz de correlaciones a partir del dataframe que agrupa características de cliente y si ha realizado o no transacciones de cada tipo, de tal forma que luego se compare cuÔles son los atributos que mayor correlación tienen.

In [0]:
# Crea el listado de columnas tanto de caracterĆ­sticas de clientes como de categorĆ­as de trasancciones
columnas_cliente = df_pagadores_dummies.columns.tolist()
columnas_trxpse = df_trxpse_x_cliente.columns.tolist()

# Genera la matriz de correlaciones completa
mat_corr = df_clientes_trxpse.corr()
In [123]:
#Luego hace una iteración por cada una de las categorías de las transacciones
for categoria in columnas_trxpse:
  
  # Dibuja el heat map asociado, dejando fijos los valores mƭnimos y mƔximos para poder comparar
  fig, axes = plt.subplots(figsize=(10, 6))
  sns.heatmap(mat_corr.loc[columnas_cliente, [categoria]].sort_values(by=categoria, ascending=False).head(10).loc[mat_corr[categoria]>0.02], 
              linewidths=.2, square=True, cbar=False, annot=True, vmin=0.0, vmax=0.3);
  plt.title(f'Principales caracterĆ­sticas para "{categoria}"');
/usr/local/lib/python3.6/dist-packages/matplotlib/axes/_base.py:3285: UserWarning: Attempting to set identical bottom==top results
in singular transformations; automatically expanding.
bottom=0, top=0
  'bottom=%s, top=%s') % (bottom, top))

Aplicaciones PFM

descargar app : http:/urlappZenAI

In [49]:
plt.figure(figsize=(15,10))
img=mpimg.imread('mock_up.jpeg')
imgplot = plt.imshow(img)
plt.axis('off');

Partiendo del concepto de un PFM , con la información de las transacciones suministrada podria ayudar a los usuarios a clasificar sus gastos realizados por PSE.

Por otro lado, las PFM podrian indentificar el comportamiento de ciertos gastos recurrentes como lo son los pagos de servicios publicos, en donde la PFM generaria recordatorios para realizar los pagos.